From 1dc213b15be362905497fae49644f47b2a67186b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:25:35 +0900 Subject: [PATCH 01/21] test(red): define normalized analysis-run registry --- tests/test_analysis_run_registry_schema.py | 548 +++++++++++++++++++++ 1 file changed, 548 insertions(+) create mode 100644 tests/test_analysis_run_registry_schema.py diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py new file mode 100644 index 000000000..f81968eda --- /dev/null +++ b/tests/test_analysis_run_registry_schema.py @@ -0,0 +1,548 @@ +"""Real-PostgreSQL contracts for the normalized Milestone 2 run registry.""" + +from __future__ import annotations + +import os +import re +import uuid +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import psycopg2 +import psycopg2.errors +import pytest +from psycopg2 import sql + +_ROOT = Path(__file__).resolve().parents[1] +_INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" +_REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" +_REGISTRY_ROLLBACK = _ROOT / "migrations" / "rollback" / "0018_analysis_run_registry.sql" +_POSTGRES_IMAGE = _ROOT / "docker" / "postgres-init" / "Dockerfile" +_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" +) +_REQUIRED_TABLES = { + "analysis_source_snapshot", + "analysis_source_count", + "analysis_run", + "analysis_run_scope", + "analysis_run_status_event", +} +_REQUIRED_LOOKUP_CODES = { + "analysis_run_lineage", + "analysis_run_report", + "analysis_run_tepp", + "analysis_status_pending", + "analysis_status_running", + "analysis_status_succeeded", + "analysis_status_failed", + "analysis_status_cancelled", + "analysis_scope_all_visible", + "analysis_scope_corporate_entity", + "analysis_scope_process_unit", + "analysis_scope_thread_group", + "analysis_count_source_row", + "analysis_count_document", + "analysis_count_thread", + "analysis_count_lineage_node", + "analysis_count_lineage_edge", +} + + +def _postgres_available() -> bool: + """Return whether the configured administrator DSN is reachable.""" + + try: + psycopg2.connect(_ADMIN_DSN, connect_timeout=2).close() + return True + except psycopg2.OperationalError: + return False + + +def _database_dsn(database_name: str) -> str: + """Replace only the database path while preserving DSN query options.""" + + parsed = urlsplit(_ADMIN_DSN) + return urlunsplit(parsed._replace(path=f"/{database_name}")) + + +def _table_definition(migration: str, table_name: str) -> str: + """Return one table definition from the deterministic migration text.""" + + match = re.search( + rf"create table if not exists {re.escape(table_name)}\s*\((.*?)\n\);", + migration, + re.IGNORECASE | re.DOTALL, + ) + assert match is not None, table_name + return match.group(1) + + +@pytest.fixture +def registry_db(): + """Yield a throwaway database migrated through the registry schema.""" + + if not _postgres_available(): + pytest.skip("a reachable PostgreSQL administrator DSN is required") + database_name = f"lineageweave_registry_{uuid.uuid4().hex[:12]}" + admin_connection = psycopg2.connect(_ADMIN_DSN) + admin_connection.autocommit = True + with admin_connection.cursor() as cursor: + cursor.execute( + sql.SQL("create database {}").format(sql.Identifier(database_name)) + ) + try: + connection = psycopg2.connect(_database_dsn(database_name)) + try: + connection.autocommit = True + with connection.cursor() as cursor: + cursor.execute(_INITIAL_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) + yield connection + finally: + connection.close() + finally: + with admin_connection.cursor() as cursor: + cursor.execute( + sql.SQL("drop database {}").format(sql.Identifier(database_name)) + ) + admin_connection.close() + + +def _insert_account(cursor, label: str = "operator") -> str: + """Insert one synthetic authenticated account and return its UUID.""" + + suffix = uuid.uuid4().hex + cursor.execute( + """ + insert into user_account + (external_subject_id, display_name, email_address) + values (%s, %s, %s) + returning user_account_id + """, + (f"{label}-{suffix}", f"{label.title()} User", f"{label}-{suffix}@example.test"), + ) + return str(cursor.fetchone()[0]) + + +def _insert_snapshot( + cursor, + *, + digest: str = "a" * 64, + maximum_available_time: str = "2026-08-15T00:00:00Z", + captured_at: str = "2026-08-15T00:05:00Z", +) -> str: + """Insert one immutable source snapshot and return its UUID.""" + + cursor.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'source-contract-v1', %s, %s) + returning analysis_source_snapshot_id + """, + (digest, maximum_available_time, captured_at), + ) + return str(cursor.fetchone()[0]) + + +def _insert_run( + cursor, + *, + snapshot_id: str, + account_id: str, + idempotency_key: str, + knowledge_cutoff: str = "2026-08-15T00:30:00Z", + run_kind_code: str = "analysis_run_lineage", +) -> str: + """Insert one immutable account-scoped analysis request.""" + + cursor.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha) + values (%s, %s, %s, %s, %s, 'lineage-run-v1', %s, %s) + returning analysis_run_id + """, + ( + snapshot_id, + run_kind_code, + idempotency_key, + account_id, + knowledge_cutoff, + "b" * 64, + "c" * 40, + ), + ) + return str(cursor.fetchone()[0]) + + +def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> None: + """Static contract rejects the parallel prototype and duplicated clocks.""" + + migration = _REGISTRY_MIGRATION.read_text(encoding="utf-8") + rollback = _REGISTRY_ROLLBACK.read_text(encoding="utf-8") + dockerfile = _POSTGRES_IMAGE.read_text(encoding="utf-8") + created_tables = set( + re.findall(r"create table if not exists\s+([a-z0-9_]+)", migration, re.I) + ) + assert _REQUIRED_TABLES <= created_tables + assert "analysis_run_records" not in created_tables + assert "metadata_payload" not in migration + assert "jsonb" not in migration.casefold() + assert _REQUIRED_LOOKUP_CODES <= set( + re.findall(r"'(analysis_[a-z0-9_]+)'", migration) + ) + assert "0018_analysis_run_registry.sql" in dockerfile + assert "analysis_run_registry_not_empty" in rollback + + snapshot_definition = _table_definition(migration, "analysis_source_snapshot") + run_definition = _table_definition(migration, "analysis_run") + assert "maximum_available_time" in snapshot_definition + assert "knowledge_cutoff" not in snapshot_definition + assert "knowledge_cutoff" in run_definition + assert "requested_by_account_id uuid not null" in run_definition + assert "unique (requested_by_account_id, idempotency_key)" in run_definition + assert "enforce_analysis_run_knowledge_cutoff" in migration + assert "reject_analysis_source_snapshot_update" in migration + assert "reject_analysis_run_update" in migration + assert "enforce_analysis_source_count_freeze" in migration + assert "enforce_analysis_run_status_transition" in migration + assert "analysis_run_current_status" in migration + + object_patterns = ( + r"create table if not exists\s+([a-z0-9_]+)", + r"create(?: unique)? index if not exists\s+([a-z0-9_]+)", + r"create or replace function\s+([a-z0-9_]+)", + r"create trigger\s+([a-z0-9_]+)", + r"create or replace view\s+([a-z0-9_]+)", + ) + for pattern in object_patterns: + for object_name in re.findall(pattern, migration, re.I): + assert len(object_name.split("_")) >= 2, object_name + + +def test_registry_migration_is_idempotent(registry_db) -> None: + """Sequential migration replay preserves one object set.""" + + with registry_db.cursor() as cursor: + cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) + cursor.execute( + "select table_name from information_schema.tables " + "where table_schema = 'public'" + ) + tables = {row[0] for row in cursor.fetchall()} + cursor.execute( + "select table_name from information_schema.views " + "where table_schema = 'public'" + ) + views = {row[0] for row in cursor.fetchall()} + assert _REQUIRED_TABLES <= tables + assert "analysis_run_current_status" in views + + +def test_registry_persists_scope_counts_and_legal_status_history(registry_db) -> None: + """A valid run keeps normalized scope, counts, and current status.""" + + with registry_db.cursor() as cursor: + account_id = _insert_account(cursor) + snapshot_id = _insert_snapshot(cursor) + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_document', 12)", + (snapshot_id,), + ) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="synthetic-run-1", + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code) " + "values (%s, 'analysis_scope_all_visible')", + (run_id,), + ) + cursor.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values + (%s, 1, 'analysis_status_pending', '2026-08-15T01:00:01Z'), + (%s, 2, 'analysis_status_running', '2026-08-15T01:00:02Z'), + (%s, 3, 'analysis_status_succeeded', '2026-08-15T01:00:03Z') + """, + (run_id, run_id, run_id), + ) + cursor.execute( + "select status_code, status_ordinal from analysis_run_current_status " + "where analysis_run_id = %s", + (run_id,), + ) + assert cursor.fetchone() == ("analysis_status_succeeded", 3) + + +def test_snapshot_supports_multiple_run_owned_cutoffs_and_blocks_future_evidence( + registry_db, +) -> None: + """One capture is reusable, but each run must respect its own cutoff.""" + + with registry_db.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + first_account_id = _insert_account(cursor, "first") + second_account_id = _insert_account(cursor, "second") + first_run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=first_account_id, + idempotency_key="cutoff-one", + knowledge_cutoff="2026-08-15T00:30:00Z", + ) + second_run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=second_account_id, + idempotency_key="cutoff-two", + knowledge_cutoff="2026-08-16T00:00:00Z", + ) + assert first_run_id != second_run_id + with pytest.raises(psycopg2.errors.RaiseException): + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=first_account_id, + idempotency_key="future-leakage", + knowledge_cutoff="2026-08-14T23:59:59Z", + ) + + +def test_snapshot_counts_and_run_request_are_immutable(registry_db) -> None: + """Evidence and request configuration freeze before derivation starts.""" + + with registry_db.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_document', 12)", + (snapshot_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "update analysis_source_snapshot set source_contract_version = 'x' " + "where analysis_source_snapshot_id = %s", + (snapshot_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "update analysis_source_count set count_value = 13 " + "where analysis_source_snapshot_id = %s", + (snapshot_id,), + ) + account_id = _insert_account(cursor) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="freeze-evidence", + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "update analysis_run set knowledge_cutoff = now() " + "where analysis_run_id = %s", + (run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_thread', 8)", + (snapshot_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "delete from analysis_source_count " + "where analysis_source_snapshot_id = %s", + (snapshot_id,), + ) + + +def test_idempotency_is_scoped_to_the_authenticated_account(registry_db) -> None: + """Two actors may use one opaque key; one actor may not reuse it.""" + + with registry_db.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + first_account_id = _insert_account(cursor, "first") + second_account_id = _insert_account(cursor, "second") + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=first_account_id, + idempotency_key="shared-key", + ) + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=second_account_id, + idempotency_key="shared-key", + ) + with pytest.raises(psycopg2.errors.UniqueViolation): + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=first_account_id, + idempotency_key="shared-key", + ) + + +def test_registry_rejects_invalid_evidence_and_missing_actor(registry_db) -> None: + """Database constraints reject malformed audit evidence before persistence.""" + + with registry_db.cursor() as cursor: + with pytest.raises(psycopg2.errors.CheckViolation): + cursor.execute( + "insert into analysis_source_snapshot " + "(snapshot_sha256, source_contract_version, " + "maximum_available_time, captured_at) " + "values ('bad', 'source-contract-v1', now(), now())" + ) + snapshot_id = _insert_snapshot(cursor) + with pytest.raises(psycopg2.errors.CheckViolation): + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_source_row', -1)", + (snapshot_id,), + ) + with pytest.raises(psycopg2.errors.NotNullViolation): + cursor.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + knowledge_cutoff, configuration_schema_version, + configuration_sha256, code_revision_sha) + values (%s, 'analysis_run_report', 'missing-actor', now(), + 'report-run-v1', %s, %s) + """, + (snapshot_id, "d" * 64, "e" * 40), + ) + + +def test_status_history_enforces_shape_order_time_and_legal_transitions( + registry_db, +) -> None: + """Append-only status evidence is a serialized state machine.""" + + with registry_db.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + first_run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="first-status", + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_running', now())", + (first_run_id,), + ) + + second_run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="second-status", + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z')", + (second_run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 3, 'analysis_status_running', " + "'2026-08-15T01:00:01Z')", + (second_run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 2, 'analysis_status_succeeded', " + "'2026-08-15T01:00:01Z')", + (second_run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 2, 'analysis_status_running', " + "'2026-08-15T01:00:02Z')", + (second_run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 3, 'analysis_status_succeeded', " + "'2026-08-15T01:00:01Z')", + (second_run_id,), + ) + with pytest.raises(psycopg2.errors.CheckViolation): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 3, 'analysis_status_failed', " + "'2026-08-15T01:00:03Z')", + (second_run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 3, 'analysis_status_succeeded', " + "'2026-08-15T01:00:03Z')", + (second_run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 4, 'analysis_status_running', " + "'2026-08-15T01:00:04Z')", + (second_run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "update analysis_run_status_event set retryable = true " + "where analysis_run_id = %s and status_ordinal = 3", + (second_run_id,), + ) + + +def test_rollback_refuses_data_loss_then_removes_an_empty_registry(registry_db) -> None: + """Downgrade fails closed until audit evidence is explicitly removed.""" + + rollback_sql = _REGISTRY_ROLLBACK.read_text(encoding="utf-8") + with registry_db.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute(rollback_sql) + registry_db.rollback() + with registry_db.cursor() as cursor: + cursor.execute( + "delete from analysis_source_snapshot " + "where analysis_source_snapshot_id = %s", + (snapshot_id,), + ) + cursor.execute(rollback_sql) + cursor.execute("select to_regclass('public.analysis_run')") + assert cursor.fetchone()[0] is None + cursor.execute(rollback_sql) From 242437ac09d08921daf8334f6d725987cb66963f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:27:13 +0900 Subject: [PATCH 02/21] feat(db): implement normalized analysis-run registry --- migrations/0018_analysis_run_registry.sql | 521 ++++++++++++++++++++++ 1 file changed, 521 insertions(+) create mode 100644 migrations/0018_analysis_run_registry.sql diff --git a/migrations/0018_analysis_run_registry.sql b/migrations/0018_analysis_run_registry.sql new file mode 100644 index 000000000..ba8d478b0 --- /dev/null +++ b/migrations/0018_analysis_run_registry.sql @@ -0,0 +1,521 @@ +-- Milestone 2 additive runtime bridge: normalized analysis-run registry. +-- +-- This migration records reproducibility, authorization scope, aggregate +-- reconciliation, and lifecycle evidence without storing source SQL, DSNs, +-- raw records, image bytes, provider payloads, credentials, or free-form JSON. +-- Snapshot availability is evidence-owned; the knowledge cutoff is run-owned, +-- so one immutable capture can support multiple historically valid analyses. + +begin; + +insert into common_lookup_value + (lookup_category, lookup_code, lookup_label, display_order) +values + ('analysis_run_kind', 'analysis_run_lineage', 'Lineage reconstruction', 0), + ('analysis_run_kind', 'analysis_run_report', 'Period report', 1), + ('analysis_run_kind', 'analysis_run_tepp', 'TEPP measurement', 2), + ('analysis_run_status', 'analysis_status_pending', 'Pending', 0), + ('analysis_run_status', 'analysis_status_running', 'Running', 1), + ('analysis_run_status', 'analysis_status_succeeded', 'Succeeded', 2), + ('analysis_run_status', 'analysis_status_failed', 'Failed', 3), + ('analysis_run_status', 'analysis_status_cancelled', 'Cancelled', 4), + ('analysis_run_scope', 'analysis_scope_all_visible', 'All authorized records', 0), + ('analysis_run_scope', 'analysis_scope_corporate_entity', 'Corporate entity', 1), + ('analysis_run_scope', 'analysis_scope_process_unit', 'Process unit', 2), + ('analysis_run_scope', 'analysis_scope_thread_group', 'Thread group', 3), + ('analysis_source_count', 'analysis_count_source_row', 'Source rows', 0), + ('analysis_source_count', 'analysis_count_document', 'Documents', 1), + ('analysis_source_count', 'analysis_count_thread', 'Threads', 2), + ('analysis_source_count', 'analysis_count_lineage_node', 'Lineage nodes', 3), + ('analysis_source_count', 'analysis_count_lineage_edge', 'Lineage edges', 4) +on conflict (lookup_code) do nothing; + +-- common_lookup_value deliberately makes lookup_code globally unique. A code +-- that already exists under another category is a migration conflict rather +-- than permission to attach the wrong vocabulary to an analysis column. +do $$ +declare + lookup_mismatch_count integer; +begin + select count(*) + into lookup_mismatch_count + from common_lookup_value as actual + join (values + ('analysis_run_lineage', 'analysis_run_kind'), + ('analysis_run_report', 'analysis_run_kind'), + ('analysis_run_tepp', 'analysis_run_kind'), + ('analysis_status_pending', 'analysis_run_status'), + ('analysis_status_running', 'analysis_run_status'), + ('analysis_status_succeeded', 'analysis_run_status'), + ('analysis_status_failed', 'analysis_run_status'), + ('analysis_status_cancelled', 'analysis_run_status'), + ('analysis_scope_all_visible', 'analysis_run_scope'), + ('analysis_scope_corporate_entity', 'analysis_run_scope'), + ('analysis_scope_process_unit', 'analysis_run_scope'), + ('analysis_scope_thread_group', 'analysis_run_scope'), + ('analysis_count_source_row', 'analysis_source_count'), + ('analysis_count_document', 'analysis_source_count'), + ('analysis_count_thread', 'analysis_source_count'), + ('analysis_count_lineage_node', 'analysis_source_count'), + ('analysis_count_lineage_edge', 'analysis_source_count') + ) as expected(lookup_code, lookup_category) + on expected.lookup_code = actual.lookup_code + where actual.lookup_category <> expected.lookup_category; + + if lookup_mismatch_count <> 0 then + raise exception 'analysis_run_registry_lookup_conflict'; + end if; +end +$$; + +create table if not exists analysis_source_snapshot ( + analysis_source_snapshot_id uuid primary key default uuid_generate_v4(), + snapshot_sha256 text not null unique, + source_contract_version text not null, + maximum_available_time timestamptz not null, + captured_at timestamptz not null, + created_at timestamptz not null default now(), + constraint analysis_source_snapshot_digest_check + check (snapshot_sha256 ~ '^[0-9a-f]{64}$'), + constraint analysis_source_snapshot_contract_check + check (length(btrim(source_contract_version)) between 1 and 128), + constraint analysis_source_snapshot_capture_check + check (maximum_available_time <= captured_at), + constraint analysis_source_snapshot_created_check + check (captured_at <= created_at) +); + +comment on table analysis_source_snapshot is + 'Immutable captured-source identity and latest evidence-availability time; ' + 'knowledge cutoffs belong to analysis_run, not the reusable snapshot.'; + +create table if not exists analysis_source_count ( + analysis_source_snapshot_id uuid not null + references analysis_source_snapshot (analysis_source_snapshot_id) + on delete cascade, + count_type_code text not null + references common_lookup_value (lookup_code), + count_value bigint not null, + primary key (analysis_source_snapshot_id, count_type_code), + constraint analysis_source_count_type_check + check (count_type_code in ( + 'analysis_count_source_row', + 'analysis_count_document', + 'analysis_count_thread', + 'analysis_count_lineage_node', + 'analysis_count_lineage_edge' + )), + constraint analysis_source_count_nonnegative_check + check (count_value >= 0) +); + +comment on table analysis_source_count is + 'One normalized aggregate reconciliation count per immutable snapshot and ' + 'count vocabulary; no source record is stored.'; + +create table if not exists analysis_run ( + analysis_run_id uuid primary key default uuid_generate_v4(), + analysis_source_snapshot_id uuid not null + references analysis_source_snapshot (analysis_source_snapshot_id), + run_kind_code text not null + references common_lookup_value (lookup_code), + requested_by_account_id uuid not null + references user_account (user_account_id), + idempotency_key text not null, + knowledge_cutoff timestamptz not null, + configuration_schema_version text not null, + configuration_sha256 text not null, + model_contract_sha256 text, + prompt_bundle_sha256 text, + code_revision_sha text not null, + requested_at timestamptz not null default now(), + constraint analysis_run_kind_check + check (run_kind_code in ( + 'analysis_run_lineage', + 'analysis_run_report', + 'analysis_run_tepp' + )), + constraint analysis_run_idempotency_key_check + check (length(btrim(idempotency_key)) between 1 and 256), + constraint analysis_run_configuration_version_check + check (length(btrim(configuration_schema_version)) between 1 and 128), + constraint analysis_run_configuration_digest_check + check (configuration_sha256 ~ '^[0-9a-f]{64}$'), + constraint analysis_run_model_digest_check + check ( + model_contract_sha256 is null + or model_contract_sha256 ~ '^[0-9a-f]{64}$' + ), + constraint analysis_run_prompt_digest_check + check ( + prompt_bundle_sha256 is null + or prompt_bundle_sha256 ~ '^[0-9a-f]{64}$' + ), + constraint analysis_run_code_revision_check + check (code_revision_sha ~ '^(?:[0-9a-f]{40}|[0-9a-f]{64})$'), + constraint analysis_run_request_time_check + check (knowledge_cutoff <= requested_at), + unique (requested_by_account_id, idempotency_key) +); + +create index if not exists analysis_run_snapshot_idx + on analysis_run (analysis_source_snapshot_id); +create index if not exists analysis_run_kind_requested_idx + on analysis_run (run_kind_code, requested_at desc); +create index if not exists analysis_run_requester_idx + on analysis_run (requested_by_account_id, requested_at desc); + +comment on table analysis_run is + 'Immutable account-scoped analysis request bound to one snapshot, one ' + 'knowledge cutoff, and reproducibility digests; lifecycle is event-derived.'; + +create table if not exists analysis_run_scope ( + analysis_run_id uuid primary key + references analysis_run (analysis_run_id) on delete cascade, + scope_kind_code text not null + references common_lookup_value (lookup_code), + corporate_entity_id uuid + references corporate_entity (corporate_entity_id), + process_unit_id uuid + references process_unit (process_unit_id), + scope_key text, + constraint analysis_run_scope_kind_check + check (scope_kind_code in ( + 'analysis_scope_all_visible', + 'analysis_scope_corporate_entity', + 'analysis_scope_process_unit', + 'analysis_scope_thread_group' + )), + constraint analysis_run_scope_shape_check + check ( + (scope_kind_code = 'analysis_scope_all_visible' + and corporate_entity_id is null + and process_unit_id is null + and scope_key is null) + or + (scope_kind_code = 'analysis_scope_corporate_entity' + and corporate_entity_id is not null + and process_unit_id is null + and scope_key is null) + or + (scope_kind_code = 'analysis_scope_process_unit' + and corporate_entity_id is null + and process_unit_id is not null + and scope_key is null) + or + (scope_kind_code = 'analysis_scope_thread_group' + and corporate_entity_id is null + and process_unit_id is null + and scope_key is not null + and length(btrim(scope_key)) between 1 and 256) + ) +); + +create index if not exists analysis_run_scope_entity_idx + on analysis_run_scope (corporate_entity_id) + where corporate_entity_id is not null; +create index if not exists analysis_run_scope_unit_idx + on analysis_run_scope (process_unit_id) + where process_unit_id is not null; + +comment on table analysis_run_scope is + 'At most one authorization-relevant product scope for an immutable run; ' + 'process-unit ownership remains derivable from process_unit.'; + +create table if not exists analysis_run_status_event ( + analysis_run_id uuid not null + references analysis_run (analysis_run_id) on delete cascade, + status_ordinal integer not null, + status_code text not null + references common_lookup_value (lookup_code), + occurred_at timestamptz not null, + recorded_at timestamptz not null default clock_timestamp(), + failure_code text, + retryable boolean not null default false, + primary key (analysis_run_id, status_ordinal), + constraint analysis_run_status_code_check + check (status_code in ( + 'analysis_status_pending', + 'analysis_status_running', + 'analysis_status_succeeded', + 'analysis_status_failed', + 'analysis_status_cancelled' + )), + constraint analysis_run_status_ordinal_check + check (status_ordinal >= 1), + constraint analysis_run_status_time_check + check (occurred_at <= recorded_at), + constraint analysis_run_status_failure_shape_check + check ( + (status_code = 'analysis_status_failed' + and failure_code is not null + and length(btrim(failure_code)) between 1 and 128) + or + (status_code <> 'analysis_status_failed' + and failure_code is null + and retryable = false) + ) +); + +create index if not exists analysis_run_status_current_idx + on analysis_run_status_event (analysis_run_id, status_ordinal desc); + +comment on table analysis_run_status_event is + 'Append-only, contiguous, monotonic state-machine evidence; failure_code is ' + 'a bounded machine code and never contains raw provider or source payloads.'; + +create or replace function reject_analysis_source_snapshot_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_source_snapshot_is_immutable'; +end +$$; + +comment on function reject_analysis_source_snapshot_update() is + 'Rejects mutation of captured source identity and availability evidence.'; + +drop trigger if exists analysis_source_snapshot_update_reject + on analysis_source_snapshot; +create trigger analysis_source_snapshot_update_reject +before update on analysis_source_snapshot +for each row execute function reject_analysis_source_snapshot_update(); + +create or replace function reject_analysis_source_count_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_source_count_is_immutable'; +end +$$; + +comment on function reject_analysis_source_count_update() is + 'Rejects replacement of a snapshot aggregate; delete and reinsert is only ' + 'permitted before the snapshot is attached to a run.'; + +drop trigger if exists analysis_source_count_update_reject + on analysis_source_count; +create trigger analysis_source_count_update_reject +before update on analysis_source_count +for each row execute function reject_analysis_source_count_update(); + +create or replace function enforce_analysis_source_count_freeze() +returns trigger +language plpgsql +as $$ +declare + affected_snapshot_id uuid; +begin + if tg_op = 'DELETE' then + affected_snapshot_id := old.analysis_source_snapshot_id; + else + affected_snapshot_id := new.analysis_source_snapshot_id; + end if; + + -- Both count mutation and run creation lock this row first. That common + -- lock order closes the race between the final count write and first run. + perform 1 + from analysis_source_snapshot + where analysis_source_snapshot_id = affected_snapshot_id + for update; + + if exists ( + select 1 + from analysis_run + where analysis_source_snapshot_id = affected_snapshot_id + ) then + raise exception 'analysis_source_count_frozen_after_run'; + end if; + + if tg_op = 'DELETE' then + return old; + end if; + return new; +end +$$; + +comment on function enforce_analysis_source_count_freeze() is + 'Serializes count insert/delete against first run creation and rejects ' + 'changes after any run references the snapshot.'; + +drop trigger if exists analysis_source_count_freeze_guard + on analysis_source_count; +create trigger analysis_source_count_freeze_guard +before insert or delete on analysis_source_count +for each row execute function enforce_analysis_source_count_freeze(); + +create or replace function enforce_analysis_run_knowledge_cutoff() +returns trigger +language plpgsql +as $$ +declare + snapshot_available_time timestamptz; + snapshot_capture_time timestamptz; +begin + select maximum_available_time, captured_at + into snapshot_available_time, snapshot_capture_time + from analysis_source_snapshot + where analysis_source_snapshot_id = new.analysis_source_snapshot_id + for update; + + if not found then + raise exception 'analysis_source_snapshot_not_found'; + end if; + if snapshot_available_time > new.knowledge_cutoff then + raise exception 'analysis_run_future_information_leakage'; + end if; + if snapshot_capture_time > new.requested_at then + raise exception 'analysis_run_snapshot_captured_after_request'; + end if; + return new; +end +$$; + +comment on function enforce_analysis_run_knowledge_cutoff() is + 'Locks the immutable snapshot and rejects run cutoffs earlier than the ' + 'latest admitted evidence or requests earlier than snapshot capture.'; + +drop trigger if exists analysis_run_knowledge_cutoff_guard + on analysis_run; +create trigger analysis_run_knowledge_cutoff_guard +before insert on analysis_run +for each row execute function enforce_analysis_run_knowledge_cutoff(); + +create or replace function reject_analysis_run_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_request_is_immutable'; +end +$$; + +comment on function reject_analysis_run_update() is + 'Rejects mutation of actor, scope root, cutoff, or reproducibility digests; ' + 'run progress belongs to append-only status events.'; + +drop trigger if exists analysis_run_update_reject + on analysis_run; +create trigger analysis_run_update_reject +before update on analysis_run +for each row execute function reject_analysis_run_update(); + +create or replace function reject_analysis_run_status_mutation() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_status_event_is_append_only'; +end +$$; + +comment on function reject_analysis_run_status_mutation() is + 'Rejects update or delete of state-machine evidence.'; + +drop trigger if exists analysis_run_status_event_update_reject + on analysis_run_status_event; +create trigger analysis_run_status_event_update_reject +before update on analysis_run_status_event +for each row execute function reject_analysis_run_status_mutation(); + +drop trigger if exists analysis_run_status_event_delete_reject + on analysis_run_status_event; +create trigger analysis_run_status_event_delete_reject +before delete on analysis_run_status_event +for each row execute function reject_analysis_run_status_mutation(); + +create or replace function enforce_analysis_run_status_transition() +returns trigger +language plpgsql +as $$ +declare + previous_ordinal integer; + previous_status_code text; + previous_occurred_at timestamptz; +begin + -- The immutable parent row is a per-run serialization lock. It prevents + -- concurrent writers from both accepting the same next ordinal. + perform 1 + from analysis_run + where analysis_run_id = new.analysis_run_id + for update; + + if not found then + raise exception 'analysis_run_not_found'; + end if; + + select status_ordinal, status_code, occurred_at + into previous_ordinal, previous_status_code, previous_occurred_at + from analysis_run_status_event + where analysis_run_id = new.analysis_run_id + order by status_ordinal desc + limit 1; + + if previous_ordinal is null then + if new.status_ordinal <> 1 + or new.status_code <> 'analysis_status_pending' then + raise exception 'analysis_run_first_status_must_be_pending'; + end if; + return new; + end if; + + if new.status_ordinal <> previous_ordinal + 1 then + raise exception 'analysis_run_status_ordinal_not_contiguous'; + end if; + if new.occurred_at < previous_occurred_at then + raise exception 'analysis_run_status_time_not_monotonic'; + end if; + + if previous_status_code = 'analysis_status_pending' then + if new.status_code not in ( + 'analysis_status_running', + 'analysis_status_cancelled' + ) then + raise exception 'analysis_run_status_transition_invalid'; + end if; + elsif previous_status_code = 'analysis_status_running' then + if new.status_code not in ( + 'analysis_status_succeeded', + 'analysis_status_failed', + 'analysis_status_cancelled' + ) then + raise exception 'analysis_run_status_transition_invalid'; + end if; + else + raise exception 'analysis_run_terminal_status_has_no_successor'; + end if; + + return new; +end +$$; + +comment on function enforce_analysis_run_status_transition() is + 'Serializes status appends and enforces pending-first, contiguous ordinals, ' + 'monotonic occurrence time, legal transitions, and terminal finality.'; + +drop trigger if exists analysis_run_status_transition_guard + on analysis_run_status_event; +create trigger analysis_run_status_transition_guard +before insert on analysis_run_status_event +for each row execute function enforce_analysis_run_status_transition(); + +create or replace view analysis_run_current_status as +select distinct on (status_event.analysis_run_id) + status_event.analysis_run_id, + status_event.status_code, + status_event.status_ordinal, + status_event.occurred_at, + status_event.recorded_at, + status_event.failure_code, + status_event.retryable + from analysis_run_status_event as status_event + order by status_event.analysis_run_id, + status_event.status_ordinal desc; + +comment on view analysis_run_current_status is + 'Latest append-only status projection for each run; never a second mutable ' + 'lifecycle authority.'; + +commit; From 2af1dcd2d5fd27d30618ae4867c85218e0f12186 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:27:35 +0900 Subject: [PATCH 03/21] feat(db): add fail-closed registry rollback --- .../rollback/0018_analysis_run_registry.sql | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 migrations/rollback/0018_analysis_run_registry.sql diff --git a/migrations/rollback/0018_analysis_run_registry.sql b/migrations/rollback/0018_analysis_run_registry.sql new file mode 100644 index 000000000..45c826002 --- /dev/null +++ b/migrations/rollback/0018_analysis_run_registry.sql @@ -0,0 +1,68 @@ +-- Fail-closed rollback for migration 0018. +-- +-- Registry evidence must be exported or explicitly deleted under an approved +-- retention procedure before these objects can be removed. Re-running this +-- rollback after a successful empty rollback is safe. + +begin; + +do $$ +declare + relation_name text; + relation_has_rows boolean; +begin + foreach relation_name in array array[ + 'analysis_run_status_event', + 'analysis_run_scope', + 'analysis_run', + 'analysis_source_count', + 'analysis_source_snapshot' + ] loop + if to_regclass('public.' || relation_name) is not null then + execute format('select exists (select 1 from %I)', relation_name) + into relation_has_rows; + if relation_has_rows then + raise exception 'analysis_run_registry_not_empty'; + end if; + end if; + end loop; +end +$$; + +drop view if exists analysis_run_current_status; +drop table if exists analysis_run_status_event; +drop table if exists analysis_run_scope; +drop table if exists analysis_run; +drop table if exists analysis_source_count; +drop table if exists analysis_source_snapshot; + +drop function if exists enforce_analysis_run_status_transition(); +drop function if exists reject_analysis_run_status_mutation(); +drop function if exists reject_analysis_run_update(); +drop function if exists enforce_analysis_run_knowledge_cutoff(); +drop function if exists enforce_analysis_source_count_freeze(); +drop function if exists reject_analysis_source_count_update(); +drop function if exists reject_analysis_source_snapshot_update(); + +delete from common_lookup_value + where lookup_code in ( + 'analysis_run_lineage', + 'analysis_run_report', + 'analysis_run_tepp', + 'analysis_status_pending', + 'analysis_status_running', + 'analysis_status_succeeded', + 'analysis_status_failed', + 'analysis_status_cancelled', + 'analysis_scope_all_visible', + 'analysis_scope_corporate_entity', + 'analysis_scope_process_unit', + 'analysis_scope_thread_group', + 'analysis_count_source_row', + 'analysis_count_document', + 'analysis_count_thread', + 'analysis_count_lineage_node', + 'analysis_count_lineage_edge' + ); + +commit; From a793c59924e9d932353a9c7ca7bb8e6195fbbe63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:28:05 +0900 Subject: [PATCH 04/21] chore(db): apply analysis registry on fresh install --- docker/postgres-init/Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index d10dec64b..d95e2c917 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -3,10 +3,9 @@ FROM postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f5 # Keycloak-database bootstrap and the product schema can be copied from # their single sources of truth. COPY docker/postgres-init/01-create-keycloak-db.sql /docker-entrypoint-initdb.d/01-create-keycloak-db.sql -# The exact same migration files tests/test_schema.py applies -- single -# source of truth, no re-typed copy. Runs against POSTGRES_DB (the "app" -# database) because docker-entrypoint-initdb.d executes each *.sql file -# with that database already selected. +# The exact same migration files the PostgreSQL contract tests apply -- single +# source of truth, no re-typed copy. docker-entrypoint-initdb.d executes each +# file against POSTGRES_DB in lexical order. COPY migrations/0001_initial_schema.sql /docker-entrypoint-initdb.d/02-app-schema.sql COPY migrations/0002_thread_grouping_keys.sql /docker-entrypoint-initdb.d/03-thread-grouping-keys.sql COPY migrations/0003_ticket_commitment_calendar.sql /docker-entrypoint-initdb.d/04-ticket-commitment-calendar.sql @@ -24,6 +23,7 @@ COPY migrations/0014_role_responsibility_team_actor_type.sql /docker-entrypoint- COPY migrations/0015_organization_name_resolution.sql /docker-entrypoint-initdb.d/16-organization-name-resolution.sql COPY migrations/0016_cross_post_actor_identity.sql /docker-entrypoint-initdb.d/17-cross-post-actor-identity.sql COPY migrations/0017_prov_o_standard_relations.sql /docker-entrypoint-initdb.d/18-prov-o-standard-relations.sql +COPY migrations/0018_analysis_run_registry.sql /docker-entrypoint-initdb.d/19-analysis-run-registry.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 From a53a3710fad1198f1a3983a9e888018283582f6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:29:36 +0900 Subject: [PATCH 05/21] docs(adr): define normalized analysis-run ownership --- .../0013-normalized-analysis-run-registry.md | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/adr/0013-normalized-analysis-run-registry.md diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md new file mode 100644 index 000000000..497c00a45 --- /dev/null +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -0,0 +1,259 @@ +# ADR 0013 — Milestone 2 uses a normalized, additive analysis-run registry + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-15 +**Depends on:** ADR 0011 standards-complete provenance separation and ADR 0012 corporate-entity creation locking + +## Context + +LineageWeave has a reviewed React/FastAPI/PostgreSQL product, compact lineage +navigation, normalized actor identity, report persistence, and a separate +standards-complete PROV-O layer. Milestone 2 must analyze operator-authorized +PostgreSQL evidence without replacing that product, duplicating cross-service +databases, or committing private source identity and content to a public +repository. + +A retained experiment proved that direct PostgreSQL analysis is feasible, but +its parallel application and denormalized run record cannot become product +truth. The product needs a small durable root that answers: + +- which immutable capture was used; +- which evidence was available by the run's knowledge cutoff; +- which authenticated account requested the work; +- which product scope and reproducibility digests governed the run; +- which aggregate counts reconcile the capture; +- which legal lifecycle transitions occurred. + +The registry does not store source SQL, DSNs, raw posts, inline images, provider +payloads, credentials, raw exceptions, or another service's application rows. + +## Decision + +Migration `0018_analysis_run_registry.sql` introduces five normalized relations +and one read projection. + +```mermaid +erDiagram + ANALYSIS_SOURCE_SNAPSHOT ||--o{ ANALYSIS_SOURCE_COUNT : reconciles + ANALYSIS_SOURCE_SNAPSHOT ||--o{ ANALYSIS_RUN : anchors + USER_ACCOUNT ||--o{ ANALYSIS_RUN : requests + ANALYSIS_RUN ||--o| ANALYSIS_RUN_SCOPE : limits + CORPORATE_ENTITY |o--o{ ANALYSIS_RUN_SCOPE : scopes + PROCESS_UNIT |o--o{ ANALYSIS_RUN_SCOPE : scopes + ANALYSIS_RUN ||--o{ ANALYSIS_RUN_STATUS_EVENT : records + + ANALYSIS_SOURCE_SNAPSHOT { + uuid analysis_source_snapshot_id PK + text snapshot_sha256 UK + text source_contract_version + timestamptz maximum_available_time + timestamptz captured_at + } + ANALYSIS_SOURCE_COUNT { + uuid analysis_source_snapshot_id PK,FK + text count_type_code PK,FK + bigint count_value + } + ANALYSIS_RUN { + uuid analysis_run_id PK + uuid analysis_source_snapshot_id FK + uuid requested_by_account_id FK + text idempotency_key UK + timestamptz knowledge_cutoff + text configuration_sha256 + text model_contract_sha256 + text prompt_bundle_sha256 + text code_revision_sha + } + ANALYSIS_RUN_SCOPE { + uuid analysis_run_id PK,FK + text scope_kind_code FK + uuid corporate_entity_id FK + uuid process_unit_id FK + text scope_key + } + ANALYSIS_RUN_STATUS_EVENT { + uuid analysis_run_id PK,FK + int status_ordinal PK + text status_code FK + timestamptz occurred_at + timestamptz recorded_at + text failure_code + boolean retryable + } +``` + +### Temporal ownership + +`analysis_source_snapshot.maximum_available_time` is an evidence fact: the +latest time at which any admitted fact became available. `analysis_run.knowledge_cutoff` +is an analysis fact: the latest information that this particular run may use. +A reusable capture therefore does **not** own one knowledge cutoff. + +Run creation locks the snapshot and requires: + +```text +maximum_available_time <= knowledge_cutoff <= requested_at +captured_at <= requested_at +``` + +This aggregate guard complements TEPP's finer event, assertion, document, +system, availability, and cutoff clocks. It does not replace TEPP temporal or +psychometric computation. + +### Identity and idempotency + +Every run references a real `user_account`. `requested_by_account_id` is not +nullable. The idempotency key is unique per authenticated account rather than +globally, because independent callers may legitimately choose the same opaque +client key. A later repository must compare request digests on retry and return +a conflict when the same account/key names different evidence or configuration. + +### Immutability and concurrency + +Snapshot identity and availability reject updates. Aggregate count values reject +updates. Count insert/delete and first run creation acquire the same snapshot-row +lock before checking whether a run exists. This shared lock order closes the +race in which a count set and first derivation could otherwise both commit. +After the first run, the complete count set is frozen. + +The analysis request row rejects updates. Lifecycle changes are represented only +by append-only status events. + +### Lifecycle state machine + +The parent run row serializes status appends. Events require contiguous +ordinals, monotonic occurrence time, and these transitions: + +```text +pending -> running | cancelled +running -> succeeded | failed | cancelled +succeeded | failed | cancelled -> terminal +``` + +The first event must be `pending`. Failed events require a bounded machine +failure code; raw exception text is prohibited. `recorded_at` is database system +time and cannot precede `occurred_at`. `analysis_run_current_status` is a view, +not a second mutable state authority. + +### Authorization scope + +`analysis_run_scope` stores at most one all-visible, corporate-entity, +process-unit, or thread-group scope. Its shape is database constrained. The +next repository/API slice must insert run, scope, and first status in one +transaction and apply the existing RBAC/ABAC contract when listing or reading +runs. This migration does not claim that an API or UI exists. + +### Service boundaries + +- **LineageWeave** owns product run identity, authorized scope, lifecycle, + aggregate reconciliation, and product-visible derivation references. +- **TEPP** owns exact evidence spans, temporal/event measurement, + multilevel/multiple-membership psychometrics, calibration, and semantic-span + budgeting through a versioned import or REST contract. +- **contextual-orchestrator** owns provider-neutral model routing and bounded + single-model versus multi-agent test-time compute allocation through its + reviewed API. +- **fast-mlsirm** owns Rust psychometric arithmetic and calibration interfaces. +- **Valkey** remains the event queue. Durable registry truth remains in + PostgreSQL; a later outbox slice bridges the two. + +No component reads another service's private application tables. + +## Alternatives considered + +### Merge the parallel experiment unchanged + +Rejected. It replaces reviewed product history, duplicates web and identity +surfaces, and creates a second database authority. + +### Store one JSON document per run + +Rejected. Signed external manifests may be JSON artifacts, but relational +identity, scope, counts, clocks, and lifecycle need independent constraints, +authorization, and query plans. + +### Put the registry only in Valkey + +Rejected. Queue state is transient and replayable. Audit identity, +idempotency, temporal eligibility, and retention evidence require PostgreSQL. + +### Store knowledge cutoff on the snapshot + +Rejected. One immutable capture can support multiple analysis requests with +different historical cutoffs. Putting the cutoff on the snapshot violates the +functional dependency and forces duplicate snapshots. + +## Security, privacy, and compliance consequences + +- Necessary PII remains in its authorized source/product tables rather than + being blanket-masked into operational uselessness. +- This registry stores opaque UUIDs, digests, bounded machine codes, aggregate + counts, and clocks only. +- Logs and public acceptance evidence must not include SQL, DSNs, raw source + text, images, secrets, provider payloads, or private source identifiers. +- Artifact bodies remain in access-controlled deployment storage and are linked + later by content digest and policy-bound reference. +- The design supports SOC 2 and CSAP evidence collection through explicit actor, + configuration, status, retention, and rollback contracts; it does not claim + certification. +- Database RLS is deferred because the current API uses one pooled service + identity and application-level RBAC/ABAC. Adopting actor-bound RLS requires a + separate ADR and transaction-scoped identity propagation. + +## Failure and rollback + +Migration replay is idempotent and rejects lookup-category collisions. The +rollback refuses to remove non-empty registry relations. Evidence must first be +exported or explicitly deleted under an approved retention procedure. An empty +rollback removes the view, tables, functions, and lookup rows and is itself +replayable. + +## Verification + +Acceptance requires: + +- real-PostgreSQL migration and replay; +- valid snapshot, aggregate, scope, and lifecycle persistence; +- distinct cutoffs over one snapshot; +- rejection of future-information leakage; +- account-scoped idempotency; +- snapshot, count, and run immutability; +- count/run concurrency serialization; +- pending-first, contiguous, monotonic, legal status transitions; +- append-only status evidence; +- fail-closed rollback; +- two-or-more-word `snake_case` database-object names; +- complete repository, security, SAST, documentation, and public-content gates + on the exact merge head. + +## Follow-up sequence + +1. Add a transaction repository that creates snapshot, counts, run, scope, and + first status atomically and compares request digests on idempotent retries. +2. Add RBAC/ABAC-protected run list/detail endpoints and the DB-grounded + read-only administrator surface. +3. Add a normalized PostgreSQL outbox and Valkey delivery worker. +4. Add TEPP and contextual-orchestrator adapters only after their versioned + contracts are present on reviewed main branches. +5. Execute private actual-data analysis and store only signed aggregate and + reproducibility manifests outside public source control. +6. Run browser E2E through real OIDC, product navigation, and evidence drill-down. + +## References — APA 7th + +International Organization for Standardization. (2019). *ISO 8601-1:2019: Date +and time—Representations for information interchange—Part 1: Basic rules* +(confirmed 2024; Amendment 1:2022). + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. +World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: +5.5. Constraints*. https://www.postgresql.org/docs/current/ddl-constraints.html + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C +Recommendation). https://www.w3.org/TR/prov-o/ + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). +https://www.w3.org/TR/owl-time/ From 52bf5a98f9be6fb1eb123ec62fb8de89916c2cfa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:30:19 +0900 Subject: [PATCH 06/21] docs(research): trace analysis registry standards --- .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md new file mode 100644 index 000000000..b439cb9ca --- /dev/null +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -0,0 +1,102 @@ +# Analysis-run registry standards and research traceability + +**Status:** Active PR evidence; not protected-main truth until merge. +**Scope:** Migration 0018, ADR 0013, rollback, and real-PostgreSQL contract tests. + +## Standards mapped to implementation + +| Source | Product implication | Implemented evidence | +|---|---|---| +| W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. | +| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. | +| ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | +| PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. | +| NIST SP 800-92 | Treat audit records as bounded, protected operational evidence rather than unstructured application logging. | Append-only status events, machine failure codes, actor identity, occurrence/record clocks, fail-closed rollback, and exclusion of raw source/provider payloads. | +| OpenAPI 3.2.0 | Define explicit versioned API schemas rather than exposing database rows or implementation-specific payloads. | API intentionally deferred; ADR 0013 requires a source-redacting run list/detail contract before a product surface is claimed. | + +## Temporal reasoning + +The registry applies a bitemporal discipline without claiming a complete +general-purpose bitemporal database: + +- `maximum_available_time` answers when the newest admitted evidence became + knowable; +- `captured_at` answers when the immutable source snapshot was materialized; +- `knowledge_cutoff` answers what a specific analysis was allowed to know; +- `requested_at` answers when that analysis was requested; +- `occurred_at` and `recorded_at` distinguish lifecycle occurrence from durable + database recording. + +The database requires the aggregate leakage boundary: + +```text +maximum_available_time <= knowledge_cutoff <= requested_at +captured_at <= requested_at +``` + +TEPP remains the authority for finer event/assertion/document/system/available +clocks and temporal psychometrics. The registry does not duplicate TEPP +measurement outputs. + +## Audit and privacy boundary + +The registry may store: + +- opaque product UUIDs; +- authenticated account UUIDs; +- SHA-256 digests; +- bounded configuration/version identifiers; +- aggregate counts; +- bounded status/failure codes; +- timezone-aware clocks. + +The registry must not store: + +- source SQL or source-table names; +- DSNs, credentials, or provider secrets; +- raw posts, HTML, images, base64 data, or attachments; +- model prompts/responses or raw exceptions; +- another service's application tables; +- organization-specific source identifiers in public fixtures or documentation. + +Necessary PII remains available in its purpose-bound authorized product/source +context. Auditability is achieved with actor identity, access control, +provenance, retention, and immutable evidence rather than blanket masking. + +## Verification matrix + +| Claim | Falsifiable test | +|---|---| +| One snapshot supports multiple analyses | Insert two runs over one snapshot with different valid cutoffs. | +| Future evidence is excluded | Reject a run whose cutoff precedes the snapshot's maximum availability time. | +| Evidence cannot change after derivation | Reject snapshot/count updates and count insert/delete after the first run. | +| Count/run race is serialized | Both paths acquire the snapshot row first; a later concurrency test must prove one legal winner and no lost freeze. | +| 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 rollback with any registry rows and allow replay after explicit cleanup. | + +## APA 7th references + +International Organization for Standardization. (2019). *ISO 8601-1:2019: Date +and time—Representations for information interchange—Part 1: Basic rules* +(confirmed 2024; Amendment 1:2022). + +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 + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. +World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ + +OpenAPI Initiative. (2025). *OpenAPI specification, version 3.2.0*. +https://spec.openapis.org/oas/v3.2.0.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: +5.5. Constraints*. https://www.postgresql.org/docs/current/ddl-constraints.html + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C +Recommendation). https://www.w3.org/TR/prov-o/ + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). +https://www.w3.org/TR/owl-time/ From b37e2aa3ed71678eff36a4576e2bf4d3d8ae24c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:30:52 +0900 Subject: [PATCH 07/21] docs(plan): sequence normalized registry delivery --- .../plans/2026-08-15-analysis-run-registry.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-15-analysis-run-registry.md diff --git a/docs/superpowers/plans/2026-08-15-analysis-run-registry.md b/docs/superpowers/plans/2026-08-15-analysis-run-registry.md new file mode 100644 index 000000000..a3ed77d27 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-analysis-run-registry.md @@ -0,0 +1,83 @@ +# Analysis-run registry implementation plan + +> Execute test-first. Preserve the reviewed LineageWeave product and keep +> private actual-data evidence outside public source control. + +**Goal:** Establish one normalized, temporally truthful, actor-scoped registry +for Milestone 2 analysis requests and lifecycle evidence. + +## Task 1 — RED: database contract + +**File:** `tests/test_analysis_run_registry_schema.py` + +1. Require the five normalized relations, current-status view, rollback, and + fresh-install wiring. +2. Reject the retained experiment's denormalized table and JSON metadata. +3. Require evidence-owned availability/capture clocks and a run-owned cutoff. +4. Require non-null requester identity and account-scoped idempotency. +5. Require immutable snapshot, count, and run request rows. +6. Require shared row locking between count mutation and first run creation. +7. Require pending-first, contiguous, monotonic, legal status transitions and + append-only status rows. +8. Require fail-closed rollback and descriptive database-object names. + +## Task 2 — GREEN: normalized migration and rollback + +**Files:** + +- `migrations/0018_analysis_run_registry.sql` +- `migrations/rollback/0018_analysis_run_registry.sql` +- `docker/postgres-init/Dockerfile` + +1. Insert category-checked lookup values idempotently. +2. Add snapshot, count, run, scope, and status-event relations in 3NF. +3. Keep `maximum_available_time` on the snapshot and `knowledge_cutoff` on the + run. +4. Serialize count freeze and run creation through the same snapshot row lock. +5. Reject mutation of immutable evidence and request configuration. +6. Implement the lifecycle state machine as a serialized insert trigger. +7. Add the current-status read view. +8. Refuse rollback while any audit evidence exists. +9. Apply migration 0018 after the PROV-O migration on fresh PostgreSQL images. + +## Task 3 — Documentation and evidence + +**Files:** + +- `docs/adr/0013-normalized-analysis-run-registry.md` +- `docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md` +- `CHANGELOG.d/milestone2-analysis-run-registry.md` + +1. Record product/service ownership and deferred API/UI claims. +2. Trace temporal, provenance, audit, privacy, concurrency, and rollback + decisions to current authoritative sources in APA 7th form. +3. Mark active-PR decisions as non-main truth. +4. Keep public fixtures synthetic and exclude private source identifiers. + +## Task 4 — Exact-head verification + +1. Run the static test without PostgreSQL and prove it fails before migration. +2. Run all registry cases against real PostgreSQL after implementation. +3. Replay the migration and rollback. +4. Run the complete Python product suite against PostgreSQL. +5. Run frontend lint, complete tests, and production build. +6. Run `compileall`, security, SAST, documentation hygiene, and public-content + scans. +7. Inspect the exact final diff for temporary workflows/scripts. +8. Obtain independent exact-head review and merge only after the parent PR is on + protected `main` and base-sensitive evidence is regenerated. + +## Task 5 — Next bounded vertical slice + +After this registry reaches protected main: + +1. Write failing repository tests for atomic run + scope + pending-event + creation and idempotent request comparison. +2. Implement the async PostgreSQL repository with no cross-service SQL. +3. Add RBAC/ABAC-protected source-redacting list/detail endpoints. +4. Add the DB-grounded read-only administrator surface and Storybook states. +5. Add normalized outbox + Valkey delivery. +6. Integrate TEPP and contextual-orchestrator only through reviewed versioned + contracts. +7. Execute private actual-data analysis and retain signed aggregate acceptance + artifacts outside public Git history. From 57a5016d96c0c9afcd8e58fc19d8014c53cf990d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:31:05 +0900 Subject: [PATCH 08/21] docs(changelog): record Milestone 2 registry slice --- .../milestone2-analysis-run-registry.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 CHANGELOG.d/milestone2-analysis-run-registry.md diff --git a/CHANGELOG.d/milestone2-analysis-run-registry.md b/CHANGELOG.d/milestone2-analysis-run-registry.md new file mode 100644 index 000000000..8552c1281 --- /dev/null +++ b/CHANGELOG.d/milestone2-analysis-run-registry.md @@ -0,0 +1,20 @@ +## Added + +- Added a normalized PostgreSQL registry for immutable source snapshots, + aggregate reconciliation counts, authenticated analysis requests, product + scopes, and append-only lifecycle evidence. +- Added a run-owned knowledge cutoff and snapshot-owned evidence-availability + clock so one capture can support multiple historically valid analyses without + future-information leakage. +- Added account-scoped idempotency, immutable request configuration, serialized + count/run locking, legal lifecycle transitions, and a derived current-status + view. +- Added fail-closed rollback, real-PostgreSQL contract tests, ADR 0013, and APA + 7th standards traceability. + +## Security + +- The registry deliberately excludes source SQL, DSNs, raw records, inline + images, provider payloads, credentials, private source identifiers, and raw + exceptions. Necessary PII remains in purpose-bound authorized product/source + contexts rather than being copied into audit metadata or blanket-masked. From 8121a572fe17f9207406d5219408a55dc45025da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:32:13 +0900 Subject: [PATCH 09/21] ci: verify clean normalized analysis registry --- .../analysis-run-registry-clean-verifier.yml | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 .github/workflows/analysis-run-registry-clean-verifier.yml diff --git a/.github/workflows/analysis-run-registry-clean-verifier.yml b/.github/workflows/analysis-run-registry-clean-verifier.yml new file mode 100644 index 000000000..cbd5e10d5 --- /dev/null +++ b/.github/workflows/analysis-run-registry-clean-verifier.yml @@ -0,0 +1,135 @@ +name: Analysis-run registry clean verifier + +on: + push: + branches: + - feat/analysis-run-registry-v079-clean + paths: + - .github/workflows/analysis-run-registry-clean-verifier.yml + +permissions: {} + +concurrency: + group: analysis-run-registry-clean-verifier + cancel-in-progress: false + +jobs: + verify-and-clean: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: write + services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres + BRANCH_NAME: feat/analysis-run-registry-v079-clean + steps: + - name: Checkout exact branch head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/analysis-run-registry-v079-clean + fetch-depth: 0 + persist-credentials: true + + - name: Record immutable verification head + run: echo "VERIFICATION_HEAD=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Select repository Rust toolchain + run: | + set -euo pipefail + rustup toolchain install 1.97.1 --profile minimal + rustup default 1.97.1 + + - name: Set up locked dependency manager + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 + with: + node-version: "24" + + - name: Install committed dependencies + run: | + set -euo pipefail + uv sync --frozen --extra dev --extra backend + corepack enable + pnpm --dir frontend install --frozen-lockfile + + - name: Require PostgreSQL rather than accept skipped contracts + run: | + set -euo pipefail + for _ in $(seq 1 30); do + pg_isready -h localhost -p 5432 -U postgres && exit 0 + sleep 2 + done + echo "PostgreSQL unavailable; registry verification is fail-closed." >&2 + exit 1 + + - name: Verify normalized registry against real PostgreSQL + run: | + set -euo pipefail + uv run --frozen python -m pytest -q tests/test_analysis_run_registry_schema.py + + - name: Verify complete Python product contracts + run: | + set -euo pipefail + uv run --frozen python -m pytest -q + uv run --frozen python -m compileall -q backend lineageweave tests + + - name: Verify React product contracts + run: | + set -euo pipefail + pnpm --dir frontend run lint + pnpm --dir frontend run test + pnpm --dir frontend run build + + - name: Verify public-content and diff hygiene + run: | + set -euo pipefail + git diff --check "${{ github.event.before }}" "$VERIFICATION_HEAD" + changed_files=$(git diff --name-only "${{ github.event.before }}" "$VERIFICATION_HEAD") + if [ -n "$changed_files" ]; then + if git grep -n -i -E 'hyosung|zcrht' "$VERIFICATION_HEAD" -- $changed_files; then + echo "Private source identifier detected in public change." >&2 + exit 1 + fi + fi + test ! -e .github/workflows/pr83-analysis-run-registry-repair.yml + test ! -e .github/workflows/pr83-analysis-run-registry-repair-v2.yml + test ! -e scripts/apply_pr83_analysis_registry_repair.py + + - name: Reject concurrent branch movement + run: | + set -euo pipefail + git fetch --no-tags origin "$BRANCH_NAME" + test "$(git rev-parse FETCH_HEAD)" = "$VERIFICATION_HEAD" + + - name: Remove verifier and publish verified product head + run: | + set -euo pipefail + rm .github/workflows/analysis-run-registry-clean-verifier.yml + git add -A + git diff --cached --check + git config user.name "opencode-agent[bot]" + git config user.email "1549082+opencode-agent[bot]@users.noreply.github.com" + git commit -m "feat(db): verify normalized analysis-run registry" + git push origin HEAD:"$BRANCH_NAME" From 94fa41ee3a68bd4e1be7f116e6e65dab47eef90e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:37:50 +0900 Subject: [PATCH 10/21] ci: use canonical locked install steps for registry verification --- .../analysis-run-registry-clean-verifier.yml | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/analysis-run-registry-clean-verifier.yml b/.github/workflows/analysis-run-registry-clean-verifier.yml index cbd5e10d5..846b05101 100644 --- a/.github/workflows/analysis-run-registry-clean-verifier.yml +++ b/.github/workflows/analysis-run-registry-clean-verifier.yml @@ -67,12 +67,15 @@ jobs: with: node-version: "24" - - name: Install committed dependencies - run: | - set -euo pipefail - uv sync --frozen --extra dev --extra backend - corepack enable - pnpm --dir frontend install --frozen-lockfile + - name: Install committed Python dependencies + run: uv sync --frozen --extra dev --extra backend + + - name: Enable Corepack + run: corepack enable + + - name: Install committed frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile - name: Require PostgreSQL rather than accept skipped contracts run: | @@ -96,11 +99,12 @@ jobs: uv run --frozen python -m compileall -q backend lineageweave tests - name: Verify React product contracts + working-directory: frontend run: | set -euo pipefail - pnpm --dir frontend run lint - pnpm --dir frontend run test - pnpm --dir frontend run build + pnpm run lint + pnpm run test + pnpm run build - name: Verify public-content and diff hygiene run: | From 61fbfb4b81c7935b7ed391ccaf5df4f92ad97a36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:51:23 +0900 Subject: [PATCH 11/21] ci: preserve analysis-run registry failure evidence --- .../analysis-run-registry-clean-verifier.yml | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/analysis-run-registry-clean-verifier.yml b/.github/workflows/analysis-run-registry-clean-verifier.yml index 846b05101..a986ad0b2 100644 --- a/.github/workflows/analysis-run-registry-clean-verifier.yml +++ b/.github/workflows/analysis-run-registry-clean-verifier.yml @@ -87,10 +87,29 @@ jobs: echo "PostgreSQL unavailable; registry verification is fail-closed." >&2 exit 1 - - name: Verify normalized registry against real PostgreSQL + - name: Capture normalized registry verification + id: registry + shell: bash run: | - set -euo pipefail - uv run --frozen python -m pytest -q tests/test_analysis_run_registry_schema.py + set +e + uv run --frozen python -m pytest -q tests/test_analysis_run_registry_schema.py \ + > /tmp/analysis-run-registry.log 2>&1 + status=$? + set -e + cat /tmp/analysis-run-registry.log + echo "status=$status" >> "$GITHUB_OUTPUT" + + - name: Upload normalized registry failure evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: analysis-run-registry-verification + path: /tmp/analysis-run-registry.log + retention-days: 3 + if-no-files-found: error + + - name: Require normalized registry verification + run: test "${{ steps.registry.outputs.status }}" = "0" - name: Verify complete Python product contracts run: | From 519cada45adb7126dfe80094437f38a50fc9b439 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:57:06 +0900 Subject: [PATCH 12/21] fix(test): roll back aborted registry downgrade transaction The fail-closed rollback script starts an explicit transaction. On an autocommit connection a RAISE left that transaction aborted, so the empty-registry cleanup could not run. --- .github/workflows/analysis-run-registry-clean-verifier.yml | 1 + tests/test_analysis_run_registry_schema.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/analysis-run-registry-clean-verifier.yml b/.github/workflows/analysis-run-registry-clean-verifier.yml index a986ad0b2..8a66ba46f 100644 --- a/.github/workflows/analysis-run-registry-clean-verifier.yml +++ b/.github/workflows/analysis-run-registry-clean-verifier.yml @@ -1,4 +1,5 @@ name: Analysis-run registry clean verifier +# Re-run after the autocommit rollback contract fix. on: push: diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index f81968eda..35756bce1 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -535,7 +535,10 @@ def test_rollback_refuses_data_loss_then_removes_an_empty_registry(registry_db) snapshot_id = _insert_snapshot(cursor) with pytest.raises(psycopg2.errors.RaiseException): cursor.execute(rollback_sql) - registry_db.rollback() + # The rollback script opens an explicit transaction on this + # autocommit connection. A RAISE leaves that transaction aborted, and + # connection.rollback() is a no-op while autocommit is true. + cursor.execute("rollback") with registry_db.cursor() as cursor: cursor.execute( "delete from analysis_source_snapshot " From 6f6a25255c8d7913667d1b6ff504d9831521efc1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:09:33 +0900 Subject: [PATCH 13/21] ci: stage analysis-run registry hardening repair --- scripts/repair_analysis_run_registry.py | 516 ++++++++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 scripts/repair_analysis_run_registry.py diff --git a/scripts/repair_analysis_run_registry.py b/scripts/repair_analysis_run_registry.py new file mode 100644 index 000000000..cf52d7a34 --- /dev/null +++ b/scripts/repair_analysis_run_registry.py @@ -0,0 +1,516 @@ +"""Temporarily harden the Milestone 2 analysis-run registry test-first.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +def replace_once(source: str, old: str, new: str, label: str) -> str: + """Replace one deterministic anchor or fail without partial output.""" + + count = source.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one anchor, found {count}") + return source.replace(old, new, 1) + + +def add_tests() -> None: + """Add failing contracts before changing the migration.""" + + path = Path("tests/test_analysis_run_registry_schema.py") + text = path.read_text(encoding="utf-8") + if "test_run_scope_and_request_evidence_are_immutable" in text: + raise SystemExit("hardening tests already exist") + + function_start = text.index("def _insert_run(") + function_end = text.index("\n\ndef test_registry_contract", function_start) + function = text[function_start:function_end] + function = replace_once( + function, + ' run_kind_code: str = "analysis_run_lineage",\n) -> str:', + ' run_kind_code: str = "analysis_run_lineage",\n' + ' requested_at: str = "2026-08-15T00:45:00Z",\n' + ') -> str:', + "run helper signature", + ) + function = replace_once( + function, + " configuration_schema_version, configuration_sha256,\n" + " code_revision_sha)\n" + " values (%s, %s, %s, %s, %s, 'lineage-run-v1', %s, %s)", + " configuration_schema_version, configuration_sha256,\n" + " code_revision_sha, requested_at)\n" + " values (%s, %s, %s, %s, %s, 'lineage-run-v1', %s, %s, %s)", + "run helper SQL", + ) + function = replace_once( + function, + ' "c" * 40,\n ),', + ' "c" * 40,\n requested_at,\n ),', + "run helper parameters", + ) + text = text[:function_start] + function + text[function_end:] + text = replace_once( + text, + ' knowledge_cutoff="2026-08-16T00:00:00Z",\n )', + ' knowledge_cutoff="2026-08-16T00:00:00Z",\n' + ' requested_at="2026-08-16T00:30:00Z",\n' + ' )', + "second cutoff request time", + ) + text = replace_once( + text, + ' assert "reject_analysis_run_update" in migration\n', + ' assert "reject_analysis_run_mutation" in migration\n' + ' assert "reject_analysis_run_scope_mutation" in migration\n' + ' assert "analysis_run_scope_required" in migration\n', + "static immutability contract", + ) + + insertion_anchor = ( + "\ndef test_rollback_refuses_data_loss_then_removes_an_empty_registry" + ) + if text.count(insertion_anchor) != 1: + raise SystemExit("registry test insertion anchor changed") + new_tests = r''' + +def test_run_scope_and_request_evidence_are_immutable(registry_db) -> None: + """Authorization scope and request identity cannot be rewritten or erased.""" + + with registry_db.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="immutable-run", + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code) " + "values (%s, 'analysis_scope_all_visible')", + (run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "update analysis_run_scope set scope_kind_code = scope_kind_code " + "where analysis_run_id = %s", + (run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "delete from analysis_run_scope where analysis_run_id = %s", + (run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "delete from analysis_run where analysis_run_id = %s", + (run_id,), + ) + + +def test_status_requires_scope_and_cannot_predate_request(registry_db) -> None: + """Lifecycle evidence starts only after an immutable authorized request.""" + + with registry_db.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="scoped-status", + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z')", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code) " + "values (%s, 'analysis_scope_all_visible')", + (run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T00:44:59Z')", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at, recorded_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z', '2099-01-01T00:00:00Z') " + "returning recorded_at", + (run_id,), + ) + recorded_at = cursor.fetchone()[0] + assert recorded_at.year < 2099 + + +def test_machine_codes_and_canonical_idempotency_are_fail_closed(registry_db) -> None: + """Audit identifiers are canonical and failure details stay machine-safe.""" + + with registry_db.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + with pytest.raises(psycopg2.errors.RaiseException): + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="future-request", + requested_at="2099-01-01T00:00:00Z", + ) + with pytest.raises(psycopg2.errors.CheckViolation): + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key=" padded-key ", + ) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="machine-safe", + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code) " + "values (%s, 'analysis_scope_all_visible')", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z')", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 2, 'analysis_status_running', " + "'2026-08-15T01:00:00Z')", + (run_id,), + ) + with pytest.raises(psycopg2.errors.CheckViolation): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at, " + "failure_code, retryable) " + "values (%s, 3, 'analysis_status_failed', " + "'2026-08-15T01:00:00Z', 'provider timeout', true)", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at, " + "failure_code, retryable) " + "values (%s, 3, 'analysis_status_failed', " + "'2026-08-15T01:00:00Z', 'provider_timeout', true)", + (run_id,), + ) +''' + text = text.replace(insertion_anchor, new_tests + insertion_anchor, 1) + path.write_text(text, encoding="utf-8") + + +def apply_implementation() -> None: + """Implement the failing audit, scope, and clock contracts.""" + + migration_path = Path("migrations/0018_analysis_run_registry.sql") + migration = migration_path.read_text(encoding="utf-8") + migration = replace_once( + migration, + " constraint analysis_run_idempotency_key_check\n" + " check (length(btrim(idempotency_key)) between 1 and 256),", + " constraint analysis_run_idempotency_key_check\n" + " check (\n" + " idempotency_key = btrim(idempotency_key)\n" + " and length(idempotency_key) between 1 and 256\n" + " and idempotency_key !~ '[[:cntrl:]]'\n" + " ),", + "canonical idempotency key", + ) + migration = replace_once( + migration, + " constraint analysis_run_configuration_version_check\n" + " check (length(btrim(configuration_schema_version)) between 1 and 128),", + " constraint analysis_run_configuration_version_check\n" + " check (\n" + " configuration_schema_version = btrim(configuration_schema_version)\n" + " and length(configuration_schema_version) between 1 and 128\n" + " ),", + "canonical configuration version", + ) + if migration.count( + "references analysis_run (analysis_run_id) on delete cascade," + ) != 2: + raise SystemExit("analysis-run cascading foreign-key anchors changed") + migration = migration.replace( + "references analysis_run (analysis_run_id) on delete cascade,", + "references analysis_run (analysis_run_id),", + 2, + ) + migration = replace_once( + migration, + " and scope_key is not null\n" + " and length(btrim(scope_key)) between 1 and 256)", + " and scope_key is not null\n" + " and scope_key = btrim(scope_key)\n" + " and length(scope_key) between 1 and 256\n" + " and scope_key !~ '[[:cntrl:]]')", + "canonical thread scope key", + ) + migration = replace_once( + migration, + " and failure_code is not null\n" + " and length(btrim(failure_code)) between 1 and 128)", + " and failure_code is not null\n" + " and failure_code ~ '^[a-z][a-z0-9_]{0,127}$')", + "machine failure code", + ) + migration = replace_once( + migration, + "begin\n" + " select maximum_available_time, captured_at\n", + "begin\n" + " if new.requested_at > clock_timestamp() then\n" + " raise exception 'analysis_run_request_time_in_future';\n" + " end if;\n\n" + " select maximum_available_time, captured_at\n", + "future request rejection", + ) + + old_run_guard = """create or replace function reject_analysis_run_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_request_is_immutable'; +end +$$; + +comment on function reject_analysis_run_update() is + 'Rejects mutation of actor, scope root, cutoff, or reproducibility digests; ' + 'run progress belongs to append-only status events.'; + +drop trigger if exists analysis_run_update_reject + on analysis_run; +create trigger analysis_run_update_reject +before update on analysis_run +for each row execute function reject_analysis_run_update(); +""" + new_run_guard = """drop trigger if exists analysis_run_update_reject + on analysis_run; +drop trigger if exists analysis_run_mutation_reject + on analysis_run; +drop function if exists reject_analysis_run_update(); + +create or replace function reject_analysis_run_mutation() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_request_is_immutable'; +end +$$; + +comment on function reject_analysis_run_mutation() is + 'Rejects update or delete of actor, cutoff, idempotency, and reproducibility ' + 'evidence; run progress belongs to append-only status events.'; + +create trigger analysis_run_mutation_reject +before update or delete on analysis_run +for each row execute function reject_analysis_run_mutation(); + +create or replace function reject_analysis_run_scope_mutation() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_scope_is_immutable'; +end +$$; + +comment on function reject_analysis_run_scope_mutation() is + 'Rejects update or delete of the authorization-relevant scope attached to ' + 'an immutable analysis request.'; + +drop trigger if exists analysis_run_scope_mutation_reject + on analysis_run_scope; +create trigger analysis_run_scope_mutation_reject +before update or delete on analysis_run_scope +for each row execute function reject_analysis_run_scope_mutation(); +""" + migration = replace_once( + migration, old_run_guard, new_run_guard, "run and scope mutation guards" + ) + migration = replace_once( + migration, + " previous_occurred_at timestamptz;\n" + "begin\n" + " -- The immutable parent row is a per-run serialization lock. It prevents\n" + " -- concurrent writers from both accepting the same next ordinal.\n" + " perform 1\n" + " from analysis_run\n" + " where analysis_run_id = new.analysis_run_id\n" + " for update;\n\n" + " if not found then\n" + " raise exception 'analysis_run_not_found';\n" + " end if;\n", + " previous_occurred_at timestamptz;\n" + " run_requested_at timestamptz;\n" + "begin\n" + " -- The immutable parent row is a per-run serialization lock. It prevents\n" + " -- concurrent writers from both accepting the same next ordinal.\n" + " select requested_at\n" + " into run_requested_at\n" + " from analysis_run\n" + " where analysis_run_id = new.analysis_run_id\n" + " for update;\n\n" + " if not found then\n" + " raise exception 'analysis_run_not_found';\n" + " end if;\n" + " if not exists (\n" + " select 1 from analysis_run_scope\n" + " where analysis_run_id = new.analysis_run_id\n" + " ) then\n" + " raise exception 'analysis_run_scope_required';\n" + " end if;\n" + " if new.occurred_at < run_requested_at then\n" + " raise exception 'analysis_run_status_before_request';\n" + " end if;\n" + " new.recorded_at := clock_timestamp();\n", + "scoped lifecycle clock guard", + ) + migration = replace_once( + migration, + "comment on function enforce_analysis_run_status_transition() is\n" + " 'Serializes status appends and enforces pending-first, contiguous ordinals, '\n" + " 'monotonic occurrence time, legal transitions, and terminal finality.';", + "comment on function enforce_analysis_run_status_transition() is\n" + " 'Serializes status appends and requires immutable scope, request-time '\n" + " 'ordering, database-recorded time, legal transitions, and terminal finality.';", + "status transition comment", + ) + migration = replace_once( + migration, + "comment on table analysis_run_scope is\n" + " 'At most one authorization-relevant product scope for an immutable run; '\n" + " 'process-unit ownership remains derivable from process_unit.';", + "comment on table analysis_run_scope is\n" + " 'One immutable authorization-relevant scope is required before lifecycle '\n" + " 'evidence; process-unit ownership remains derivable from process_unit.';", + "scope table comment", + ) + migration_path.write_text(migration, encoding="utf-8") + + rollback_path = Path("migrations/rollback/0018_analysis_run_registry.sql") + rollback = rollback_path.read_text(encoding="utf-8") + rollback = replace_once( + rollback, + "drop function if exists reject_analysis_run_update();\n", + "drop function if exists reject_analysis_run_scope_mutation();\n" + "drop function if exists reject_analysis_run_mutation();\n" + "drop function if exists reject_analysis_run_update();\n", + "rollback mutation functions", + ) + rollback_path.write_text(rollback, encoding="utf-8") + + adr_path = Path("docs/adr/0013-normalized-analysis-run-registry.md") + adr = adr_path.read_text(encoding="utf-8") + adr = replace_once( + adr, + "The analysis request row rejects updates. Lifecycle changes are represented only\n" + "by append-only status events.", + "The analysis request and its authorization scope reject updates and deletes.\n" + "Lifecycle changes are represented only by append-only status events, so a cascade\n" + "cannot erase the derivation root or its access boundary.", + "ADR immutability", + ) + adr = replace_once( + adr, + "The first event must be `pending`. Failed events require a bounded machine\n" + "failure code; raw exception text is prohibited. `recorded_at` is database system\n" + "time and cannot precede `occurred_at`. `analysis_run_current_status` is a view,\n" + "not a second mutable state authority.", + "The first event must be `pending`, requires an immutable scope, and cannot predate\n" + "the run request. Failed events require a lowercase machine-code identifier; raw\n" + "exception text is prohibited. `recorded_at` is overwritten with database system\n" + "time on every insert and cannot precede `occurred_at`.\n" + "`analysis_run_current_status` is a view, not a second mutable state authority.", + "ADR lifecycle", + ) + adr = replace_once( + adr, + "`analysis_run_scope` stores at most one all-visible, corporate-entity,\n" + "process-unit, or thread-group scope. Its shape is database constrained. The\n" + "next repository/API slice must insert run, scope, and first status in one\n", + "`analysis_run_scope` stores one immutable all-visible, corporate-entity,\n" + "process-unit, or thread-group scope. Its shape is database constrained and the\n" + "first lifecycle event is rejected until it exists. The next repository/API slice\n" + "must insert run, scope, and first status in one\n", + "ADR authorization scope", + ) + adr = replace_once( + adr, + "Every run references a real `user_account`. `requested_by_account_id` is not\n" + "nullable. The idempotency key is unique per authenticated account rather than\n", + "Every run references a real `user_account`. `requested_by_account_id` is not\n" + "nullable. Idempotency keys are trimmed, control-free canonical values and are\n" + "unique per authenticated account rather than\n", + "ADR idempotency", + ) + adr = replace_once( + adr, + "- snapshot, count, and run immutability;\n", + "- snapshot, count, run, and authorization-scope immutability;\n" + "- deletion resistance for request and scope audit evidence;\n" + "- scope-required lifecycle, request-time ordering, and database-owned record time;\n" + "- canonical idempotency and bounded machine-code failure identifiers;\n", + "ADR verification", + ) + adr_path.write_text(adr, encoding="utf-8") + + changelog_path = Path("CHANGELOG.d/milestone2-analysis-run-registry.md") + changelog = changelog_path.read_text(encoding="utf-8") + changelog = replace_once( + changelog, + "- Added account-scoped idempotency, immutable request configuration, serialized\n" + " count/run locking, legal lifecycle transitions, and a derived current-status\n" + " view.", + "- Added canonical account-scoped idempotency, immutable request and scope evidence,\n" + " deletion resistance, serialized count/run locking, scope-required request-time-\n" + " ordered lifecycle transitions, database-owned record time, and a derived\n" + " current-status view.", + "changelog hardening", + ) + changelog_path.write_text(changelog, encoding="utf-8") + + +def main() -> None: + """Dispatch the requested deterministic repair phase.""" + + parser = argparse.ArgumentParser() + parser.add_argument("phase", choices=("add-tests", "apply")) + args = parser.parse_args() + if args.phase == "add-tests": + add_tests() + else: + apply_implementation() + + +if __name__ == "__main__": + main() From fac45ceb3d26a0c86919dd81c812034524d57f02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:11:32 +0900 Subject: [PATCH 14/21] ci: verify immutable analysis-run evidence test-first --- .../analysis-run-registry-clean-verifier.yml | 97 ++++++++++++++++--- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/.github/workflows/analysis-run-registry-clean-verifier.yml b/.github/workflows/analysis-run-registry-clean-verifier.yml index 8a66ba46f..e60a58f12 100644 --- a/.github/workflows/analysis-run-registry-clean-verifier.yml +++ b/.github/workflows/analysis-run-registry-clean-verifier.yml @@ -1,5 +1,4 @@ name: Analysis-run registry clean verifier -# Re-run after the autocommit rollback contract fix. on: push: @@ -43,8 +42,13 @@ jobs: fetch-depth: 0 persist-credentials: true - - name: Record immutable verification head - run: echo "VERIFICATION_HEAD=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + - name: Reject stale or reordered execution + env: + EXPECTED_PARENT_SHA: 552eb6cf52d287cf71b2e65cba315bd57b8a14af + run: | + set -euo pipefail + test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT_SHA" + echo "VERIFICATION_HEAD=$(git rev-parse HEAD)" >> "$GITHUB_ENV" - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 @@ -88,6 +92,73 @@ jobs: echo "PostgreSQL unavailable; registry verification is fail-closed." >&2 exit 1 + - name: Compile the bounded repair helper + run: python -m py_compile scripts/repair_analysis_run_registry.py + + - name: Add failing audit and lifecycle contracts + run: | + set -euo pipefail + python scripts/repair_analysis_run_registry.py add-tests + python - <<'PY' + from pathlib import Path + + path = Path("tests/test_analysis_run_registry_schema.py") + text = path.read_text(encoding="utf-8") + + def replace_once(source: str, old: str, new: str, label: str) -> str: + count = source.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one anchor, found {count}") + return source.replace(old, new, 1) + + for run_variable, idempotency_key in ( + ("first_run_id", "first-status"), + ("second_run_id", "second-status"), + ): + anchor = ( + f' {run_variable} = _insert_run(\n' + ' cursor,\n' + ' snapshot_id=snapshot_id,\n' + ' account_id=account_id,\n' + f' idempotency_key="{idempotency_key}",\n' + ' )\n' + ) + scope_insert = ( + anchor + + ' cursor.execute(\n' + + ' "insert into analysis_run_scope "\n' + + ' "(analysis_run_id, scope_kind_code) "\n' + + ' "values (%s, \'analysis_scope_all_visible\')",\n' + + f' ({run_variable},),\n' + + ' )\n' + ) + text = replace_once( + text, + anchor, + scope_insert, + f"{idempotency_key} scope fixture", + ) + + path.write_text(text, encoding="utf-8") + PY + + - name: Prove the hardened registry contracts are red + run: | + set -euo pipefail + grep -F "test_run_scope_and_request_evidence_are_immutable" tests/test_analysis_run_registry_schema.py + grep -F "test_status_requires_scope_and_cannot_predate_request" tests/test_analysis_run_registry_schema.py + grep -F "test_machine_codes_and_canonical_idempotency_are_fail_closed" tests/test_analysis_run_registry_schema.py + set +e + uv run --frozen python -m pytest -q tests/test_analysis_run_registry_schema.py \ + > /tmp/analysis-run-registry-red.log 2>&1 + status=$? + set -e + cat /tmp/analysis-run-registry-red.log + test "$status" -ne 0 + + - name: Implement immutable request scope and lifecycle evidence + run: python scripts/repair_analysis_run_registry.py apply + - name: Capture normalized registry verification id: registry shell: bash @@ -100,14 +171,16 @@ jobs: cat /tmp/analysis-run-registry.log echo "status=$status" >> "$GITHUB_OUTPUT" - - name: Upload normalized registry failure evidence + - name: Upload normalized registry evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: analysis-run-registry-verification - path: /tmp/analysis-run-registry.log + path: | + /tmp/analysis-run-registry-red.log + /tmp/analysis-run-registry.log retention-days: 3 - if-no-files-found: error + if-no-files-found: warn - name: Require normalized registry verification run: test "${{ steps.registry.outputs.status }}" = "0" @@ -129,10 +202,10 @@ jobs: - name: Verify public-content and diff hygiene run: | set -euo pipefail - git diff --check "${{ github.event.before }}" "$VERIFICATION_HEAD" - changed_files=$(git diff --name-only "${{ github.event.before }}" "$VERIFICATION_HEAD") + git diff --check + changed_files=$(git diff --name-only "$VERIFICATION_HEAD") if [ -n "$changed_files" ]; then - if git grep -n -i -E 'hyosung|zcrht' "$VERIFICATION_HEAD" -- $changed_files; then + if grep -n -i -E 'hyosung|zcrht' $changed_files; then echo "Private source identifier detected in public change." >&2 exit 1 fi @@ -150,10 +223,12 @@ jobs: - name: Remove verifier and publish verified product head run: | set -euo pipefail - rm .github/workflows/analysis-run-registry-clean-verifier.yml + rm \ + .github/workflows/analysis-run-registry-clean-verifier.yml \ + scripts/repair_analysis_run_registry.py git add -A git diff --cached --check git config user.name "opencode-agent[bot]" git config user.email "1549082+opencode-agent[bot]@users.noreply.github.com" - git commit -m "feat(db): verify normalized analysis-run registry" + git commit -m "fix(db): make analysis-run evidence immutable" git push origin HEAD:"$BRANCH_NAME" From aef0d16f46647cbdf2ee61a3eebff4421bd5a553 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <1549082+opencode-agent[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:53:12 +0000 Subject: [PATCH 15/21] fix(db): make analysis-run evidence immutable --- .../analysis-run-registry-clean-verifier.yml | 234 -------- .../milestone2-analysis-run-registry.md | 7 +- .../0013-normalized-analysis-run-registry.md | 29 +- migrations/0018_analysis_run_registry.sql | 88 ++- .../rollback/0018_analysis_run_registry.sql | 2 + scripts/repair_analysis_run_registry.py | 516 ------------------ tests/test_analysis_run_registry_schema.py | 171 +++++- 7 files changed, 260 insertions(+), 787 deletions(-) delete mode 100644 .github/workflows/analysis-run-registry-clean-verifier.yml delete mode 100644 scripts/repair_analysis_run_registry.py diff --git a/.github/workflows/analysis-run-registry-clean-verifier.yml b/.github/workflows/analysis-run-registry-clean-verifier.yml deleted file mode 100644 index e60a58f12..000000000 --- a/.github/workflows/analysis-run-registry-clean-verifier.yml +++ /dev/null @@ -1,234 +0,0 @@ -name: Analysis-run registry clean verifier - -on: - push: - branches: - - feat/analysis-run-registry-v079-clean - paths: - - .github/workflows/analysis-run-registry-clean-verifier.yml - -permissions: {} - -concurrency: - group: analysis-run-registry-clean-verifier - cancel-in-progress: false - -jobs: - verify-and-clean: - runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - contents: write - services: - postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres - BRANCH_NAME: feat/analysis-run-registry-v079-clean - steps: - - name: Checkout exact branch head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: feat/analysis-run-registry-v079-clean - fetch-depth: 0 - persist-credentials: true - - - name: Reject stale or reordered execution - env: - EXPECTED_PARENT_SHA: 552eb6cf52d287cf71b2e65cba315bd57b8a14af - run: | - set -euo pipefail - test "$(git rev-parse HEAD^)" = "$EXPECTED_PARENT_SHA" - echo "VERIFICATION_HEAD=$(git rev-parse HEAD)" >> "$GITHUB_ENV" - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Select repository Rust toolchain - run: | - set -euo pipefail - rustup toolchain install 1.97.1 --profile minimal - rustup default 1.97.1 - - - name: Set up locked dependency manager - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - version: "0.11.28" - enable-cache: false - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Install committed Python dependencies - run: uv sync --frozen --extra dev --extra backend - - - name: Enable Corepack - run: corepack enable - - - name: Install committed frontend dependencies - working-directory: frontend - run: pnpm install --frozen-lockfile - - - name: Require PostgreSQL rather than accept skipped contracts - run: | - set -euo pipefail - for _ in $(seq 1 30); do - pg_isready -h localhost -p 5432 -U postgres && exit 0 - sleep 2 - done - echo "PostgreSQL unavailable; registry verification is fail-closed." >&2 - exit 1 - - - name: Compile the bounded repair helper - run: python -m py_compile scripts/repair_analysis_run_registry.py - - - name: Add failing audit and lifecycle contracts - run: | - set -euo pipefail - python scripts/repair_analysis_run_registry.py add-tests - python - <<'PY' - from pathlib import Path - - path = Path("tests/test_analysis_run_registry_schema.py") - text = path.read_text(encoding="utf-8") - - def replace_once(source: str, old: str, new: str, label: str) -> str: - count = source.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one anchor, found {count}") - return source.replace(old, new, 1) - - for run_variable, idempotency_key in ( - ("first_run_id", "first-status"), - ("second_run_id", "second-status"), - ): - anchor = ( - f' {run_variable} = _insert_run(\n' - ' cursor,\n' - ' snapshot_id=snapshot_id,\n' - ' account_id=account_id,\n' - f' idempotency_key="{idempotency_key}",\n' - ' )\n' - ) - scope_insert = ( - anchor - + ' cursor.execute(\n' - + ' "insert into analysis_run_scope "\n' - + ' "(analysis_run_id, scope_kind_code) "\n' - + ' "values (%s, \'analysis_scope_all_visible\')",\n' - + f' ({run_variable},),\n' - + ' )\n' - ) - text = replace_once( - text, - anchor, - scope_insert, - f"{idempotency_key} scope fixture", - ) - - path.write_text(text, encoding="utf-8") - PY - - - name: Prove the hardened registry contracts are red - run: | - set -euo pipefail - grep -F "test_run_scope_and_request_evidence_are_immutable" tests/test_analysis_run_registry_schema.py - grep -F "test_status_requires_scope_and_cannot_predate_request" tests/test_analysis_run_registry_schema.py - grep -F "test_machine_codes_and_canonical_idempotency_are_fail_closed" tests/test_analysis_run_registry_schema.py - set +e - uv run --frozen python -m pytest -q tests/test_analysis_run_registry_schema.py \ - > /tmp/analysis-run-registry-red.log 2>&1 - status=$? - set -e - cat /tmp/analysis-run-registry-red.log - test "$status" -ne 0 - - - name: Implement immutable request scope and lifecycle evidence - run: python scripts/repair_analysis_run_registry.py apply - - - name: Capture normalized registry verification - id: registry - shell: bash - run: | - set +e - uv run --frozen python -m pytest -q tests/test_analysis_run_registry_schema.py \ - > /tmp/analysis-run-registry.log 2>&1 - status=$? - set -e - cat /tmp/analysis-run-registry.log - echo "status=$status" >> "$GITHUB_OUTPUT" - - - name: Upload normalized registry evidence - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: analysis-run-registry-verification - path: | - /tmp/analysis-run-registry-red.log - /tmp/analysis-run-registry.log - retention-days: 3 - if-no-files-found: warn - - - name: Require normalized registry verification - run: test "${{ steps.registry.outputs.status }}" = "0" - - - name: Verify complete Python product contracts - run: | - set -euo pipefail - uv run --frozen python -m pytest -q - uv run --frozen python -m compileall -q backend lineageweave tests - - - name: Verify React product contracts - working-directory: frontend - run: | - set -euo pipefail - pnpm run lint - pnpm run test - pnpm run build - - - name: Verify public-content and diff hygiene - run: | - set -euo pipefail - git diff --check - changed_files=$(git diff --name-only "$VERIFICATION_HEAD") - if [ -n "$changed_files" ]; then - if grep -n -i -E 'hyosung|zcrht' $changed_files; then - echo "Private source identifier detected in public change." >&2 - exit 1 - fi - fi - test ! -e .github/workflows/pr83-analysis-run-registry-repair.yml - test ! -e .github/workflows/pr83-analysis-run-registry-repair-v2.yml - test ! -e scripts/apply_pr83_analysis_registry_repair.py - - - name: Reject concurrent branch movement - run: | - set -euo pipefail - git fetch --no-tags origin "$BRANCH_NAME" - test "$(git rev-parse FETCH_HEAD)" = "$VERIFICATION_HEAD" - - - name: Remove verifier and publish verified product head - run: | - set -euo pipefail - rm \ - .github/workflows/analysis-run-registry-clean-verifier.yml \ - scripts/repair_analysis_run_registry.py - git add -A - git diff --cached --check - git config user.name "opencode-agent[bot]" - git config user.email "1549082+opencode-agent[bot]@users.noreply.github.com" - git commit -m "fix(db): make analysis-run evidence immutable" - git push origin HEAD:"$BRANCH_NAME" diff --git a/CHANGELOG.d/milestone2-analysis-run-registry.md b/CHANGELOG.d/milestone2-analysis-run-registry.md index 8552c1281..5d7e0288f 100644 --- a/CHANGELOG.d/milestone2-analysis-run-registry.md +++ b/CHANGELOG.d/milestone2-analysis-run-registry.md @@ -6,9 +6,10 @@ - Added a run-owned knowledge cutoff and snapshot-owned evidence-availability clock so one capture can support multiple historically valid analyses without future-information leakage. -- Added account-scoped idempotency, immutable request configuration, serialized - count/run locking, legal lifecycle transitions, and a derived current-status - view. +- Added canonical account-scoped idempotency, immutable request and scope evidence, + deletion resistance, serialized count/run locking, scope-required request-time- + ordered lifecycle transitions, database-owned record time, and a derived + current-status view. - Added fail-closed rollback, real-PostgreSQL contract tests, ADR 0013, and APA 7th standards traceability. diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index 497c00a45..1d5a59866 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -104,7 +104,8 @@ psychometric computation. ### Identity and idempotency Every run references a real `user_account`. `requested_by_account_id` is not -nullable. The idempotency key is unique per authenticated account rather than +nullable. Idempotency keys are trimmed, control-free canonical values and are +unique per authenticated account rather than globally, because independent callers may legitimately choose the same opaque client key. A later repository must compare request digests on retry and return a conflict when the same account/key names different evidence or configuration. @@ -117,8 +118,9 @@ lock before checking whether a run exists. This shared lock order closes the race in which a count set and first derivation could otherwise both commit. After the first run, the complete count set is frozen. -The analysis request row rejects updates. Lifecycle changes are represented only -by append-only status events. +The analysis request and its authorization scope reject updates and deletes. +Lifecycle changes are represented only by append-only status events, so a cascade +cannot erase the derivation root or its access boundary. ### Lifecycle state machine @@ -131,16 +133,18 @@ running -> succeeded | failed | cancelled succeeded | failed | cancelled -> terminal ``` -The first event must be `pending`. Failed events require a bounded machine -failure code; raw exception text is prohibited. `recorded_at` is database system -time and cannot precede `occurred_at`. `analysis_run_current_status` is a view, -not a second mutable state authority. +The first event must be `pending`, requires an immutable scope, and cannot predate +the run request. Failed events require a lowercase machine-code identifier; raw +exception text is prohibited. `recorded_at` is overwritten with database system +time on every insert and cannot precede `occurred_at`. +`analysis_run_current_status` is a view, not a second mutable state authority. ### Authorization scope -`analysis_run_scope` stores at most one all-visible, corporate-entity, -process-unit, or thread-group scope. Its shape is database constrained. The -next repository/API slice must insert run, scope, and first status in one +`analysis_run_scope` stores one immutable all-visible, corporate-entity, +process-unit, or thread-group scope. Its shape is database constrained and the +first lifecycle event is rejected until it exists. The next repository/API slice +must insert run, scope, and first status in one transaction and apply the existing RBAC/ABAC contract when listing or reading runs. This migration does not claim that an API or UI exists. @@ -218,7 +222,10 @@ Acceptance requires: - distinct cutoffs over one snapshot; - rejection of future-information leakage; - account-scoped idempotency; -- snapshot, count, and run immutability; +- snapshot, count, run, and authorization-scope immutability; +- deletion resistance for request and scope audit evidence; +- scope-required lifecycle, request-time ordering, and database-owned record time; +- canonical idempotency and bounded machine-code failure identifiers; - count/run concurrency serialization; - pending-first, contiguous, monotonic, legal status transitions; - append-only status evidence; diff --git a/migrations/0018_analysis_run_registry.sql b/migrations/0018_analysis_run_registry.sql index ba8d478b0..b08d80b3b 100644 --- a/migrations/0018_analysis_run_registry.sql +++ b/migrations/0018_analysis_run_registry.sql @@ -136,9 +136,16 @@ create table if not exists analysis_run ( 'analysis_run_tepp' )), constraint analysis_run_idempotency_key_check - check (length(btrim(idempotency_key)) between 1 and 256), + check ( + idempotency_key = btrim(idempotency_key) + and length(idempotency_key) between 1 and 256 + and idempotency_key !~ '[[:cntrl:]]' + ), constraint analysis_run_configuration_version_check - check (length(btrim(configuration_schema_version)) between 1 and 128), + check ( + configuration_schema_version = btrim(configuration_schema_version) + and length(configuration_schema_version) between 1 and 128 + ), constraint analysis_run_configuration_digest_check check (configuration_sha256 ~ '^[0-9a-f]{64}$'), constraint analysis_run_model_digest_check @@ -171,7 +178,7 @@ comment on table analysis_run is create table if not exists analysis_run_scope ( analysis_run_id uuid primary key - references analysis_run (analysis_run_id) on delete cascade, + references analysis_run (analysis_run_id), scope_kind_code text not null references common_lookup_value (lookup_code), corporate_entity_id uuid @@ -207,7 +214,9 @@ create table if not exists analysis_run_scope ( and corporate_entity_id is null and process_unit_id is null and scope_key is not null - and length(btrim(scope_key)) between 1 and 256) + and scope_key = btrim(scope_key) + and length(scope_key) between 1 and 256 + and scope_key !~ '[[:cntrl:]]') ) ); @@ -219,12 +228,12 @@ create index if not exists analysis_run_scope_unit_idx where process_unit_id is not null; comment on table analysis_run_scope is - 'At most one authorization-relevant product scope for an immutable run; ' - 'process-unit ownership remains derivable from process_unit.'; + 'One immutable authorization-relevant scope is required before lifecycle ' + 'evidence; process-unit ownership remains derivable from process_unit.'; create table if not exists analysis_run_status_event ( analysis_run_id uuid not null - references analysis_run (analysis_run_id) on delete cascade, + references analysis_run (analysis_run_id), status_ordinal integer not null, status_code text not null references common_lookup_value (lookup_code), @@ -249,7 +258,7 @@ create table if not exists analysis_run_status_event ( check ( (status_code = 'analysis_status_failed' and failure_code is not null - and length(btrim(failure_code)) between 1 and 128) + and failure_code ~ '^[a-z][a-z0-9_]{0,127}$') or (status_code <> 'analysis_status_failed' and failure_code is null @@ -354,6 +363,10 @@ declare snapshot_available_time timestamptz; snapshot_capture_time timestamptz; begin + if new.requested_at > clock_timestamp() then + raise exception 'analysis_run_request_time_in_future'; + end if; + select maximum_available_time, captured_at into snapshot_available_time, snapshot_capture_time from analysis_source_snapshot @@ -383,7 +396,13 @@ create trigger analysis_run_knowledge_cutoff_guard before insert on analysis_run for each row execute function enforce_analysis_run_knowledge_cutoff(); -create or replace function reject_analysis_run_update() +drop trigger if exists analysis_run_update_reject + on analysis_run; +drop trigger if exists analysis_run_mutation_reject + on analysis_run; +drop function if exists reject_analysis_run_update(); + +create or replace function reject_analysis_run_mutation() returns trigger language plpgsql as $$ @@ -392,15 +411,32 @@ begin end $$; -comment on function reject_analysis_run_update() is - 'Rejects mutation of actor, scope root, cutoff, or reproducibility digests; ' - 'run progress belongs to append-only status events.'; +comment on function reject_analysis_run_mutation() is + 'Rejects update or delete of actor, cutoff, idempotency, and reproducibility ' + 'evidence; run progress belongs to append-only status events.'; -drop trigger if exists analysis_run_update_reject - on analysis_run; -create trigger analysis_run_update_reject -before update on analysis_run -for each row execute function reject_analysis_run_update(); +create trigger analysis_run_mutation_reject +before update or delete on analysis_run +for each row execute function reject_analysis_run_mutation(); + +create or replace function reject_analysis_run_scope_mutation() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_scope_is_immutable'; +end +$$; + +comment on function reject_analysis_run_scope_mutation() is + 'Rejects update or delete of the authorization-relevant scope attached to ' + 'an immutable analysis request.'; + +drop trigger if exists analysis_run_scope_mutation_reject + on analysis_run_scope; +create trigger analysis_run_scope_mutation_reject +before update or delete on analysis_run_scope +for each row execute function reject_analysis_run_scope_mutation(); create or replace function reject_analysis_run_status_mutation() returns trigger @@ -434,10 +470,12 @@ declare previous_ordinal integer; previous_status_code text; previous_occurred_at timestamptz; + run_requested_at timestamptz; begin -- The immutable parent row is a per-run serialization lock. It prevents -- concurrent writers from both accepting the same next ordinal. - perform 1 + select requested_at + into run_requested_at from analysis_run where analysis_run_id = new.analysis_run_id for update; @@ -445,6 +483,16 @@ begin if not found then raise exception 'analysis_run_not_found'; end if; + if not exists ( + select 1 from analysis_run_scope + where analysis_run_id = new.analysis_run_id + ) then + raise exception 'analysis_run_scope_required'; + end if; + if new.occurred_at < run_requested_at then + raise exception 'analysis_run_status_before_request'; + end if; + new.recorded_at := clock_timestamp(); select status_ordinal, status_code, occurred_at into previous_ordinal, previous_status_code, previous_occurred_at @@ -492,8 +540,8 @@ end $$; comment on function enforce_analysis_run_status_transition() is - 'Serializes status appends and enforces pending-first, contiguous ordinals, ' - 'monotonic occurrence time, legal transitions, and terminal finality.'; + 'Serializes status appends and requires immutable scope, request-time ' + 'ordering, database-recorded time, legal transitions, and terminal finality.'; drop trigger if exists analysis_run_status_transition_guard on analysis_run_status_event; diff --git a/migrations/rollback/0018_analysis_run_registry.sql b/migrations/rollback/0018_analysis_run_registry.sql index 45c826002..f91abc47c 100644 --- a/migrations/rollback/0018_analysis_run_registry.sql +++ b/migrations/rollback/0018_analysis_run_registry.sql @@ -38,6 +38,8 @@ drop table if exists analysis_source_snapshot; drop function if exists enforce_analysis_run_status_transition(); drop function if exists reject_analysis_run_status_mutation(); +drop function if exists reject_analysis_run_scope_mutation(); +drop function if exists reject_analysis_run_mutation(); drop function if exists reject_analysis_run_update(); drop function if exists enforce_analysis_run_knowledge_cutoff(); drop function if exists enforce_analysis_source_count_freeze(); diff --git a/scripts/repair_analysis_run_registry.py b/scripts/repair_analysis_run_registry.py deleted file mode 100644 index cf52d7a34..000000000 --- a/scripts/repair_analysis_run_registry.py +++ /dev/null @@ -1,516 +0,0 @@ -"""Temporarily harden the Milestone 2 analysis-run registry test-first.""" - -from __future__ import annotations - -import argparse -from pathlib import Path - - -def replace_once(source: str, old: str, new: str, label: str) -> str: - """Replace one deterministic anchor or fail without partial output.""" - - count = source.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one anchor, found {count}") - return source.replace(old, new, 1) - - -def add_tests() -> None: - """Add failing contracts before changing the migration.""" - - path = Path("tests/test_analysis_run_registry_schema.py") - text = path.read_text(encoding="utf-8") - if "test_run_scope_and_request_evidence_are_immutable" in text: - raise SystemExit("hardening tests already exist") - - function_start = text.index("def _insert_run(") - function_end = text.index("\n\ndef test_registry_contract", function_start) - function = text[function_start:function_end] - function = replace_once( - function, - ' run_kind_code: str = "analysis_run_lineage",\n) -> str:', - ' run_kind_code: str = "analysis_run_lineage",\n' - ' requested_at: str = "2026-08-15T00:45:00Z",\n' - ') -> str:', - "run helper signature", - ) - function = replace_once( - function, - " configuration_schema_version, configuration_sha256,\n" - " code_revision_sha)\n" - " values (%s, %s, %s, %s, %s, 'lineage-run-v1', %s, %s)", - " configuration_schema_version, configuration_sha256,\n" - " code_revision_sha, requested_at)\n" - " values (%s, %s, %s, %s, %s, 'lineage-run-v1', %s, %s, %s)", - "run helper SQL", - ) - function = replace_once( - function, - ' "c" * 40,\n ),', - ' "c" * 40,\n requested_at,\n ),', - "run helper parameters", - ) - text = text[:function_start] + function + text[function_end:] - text = replace_once( - text, - ' knowledge_cutoff="2026-08-16T00:00:00Z",\n )', - ' knowledge_cutoff="2026-08-16T00:00:00Z",\n' - ' requested_at="2026-08-16T00:30:00Z",\n' - ' )', - "second cutoff request time", - ) - text = replace_once( - text, - ' assert "reject_analysis_run_update" in migration\n', - ' assert "reject_analysis_run_mutation" in migration\n' - ' assert "reject_analysis_run_scope_mutation" in migration\n' - ' assert "analysis_run_scope_required" in migration\n', - "static immutability contract", - ) - - insertion_anchor = ( - "\ndef test_rollback_refuses_data_loss_then_removes_an_empty_registry" - ) - if text.count(insertion_anchor) != 1: - raise SystemExit("registry test insertion anchor changed") - new_tests = r''' - -def test_run_scope_and_request_evidence_are_immutable(registry_db) -> None: - """Authorization scope and request identity cannot be rewritten or erased.""" - - with registry_db.cursor() as cursor: - snapshot_id = _insert_snapshot(cursor) - account_id = _insert_account(cursor) - run_id = _insert_run( - cursor, - snapshot_id=snapshot_id, - account_id=account_id, - idempotency_key="immutable-run", - ) - cursor.execute( - "insert into analysis_run_scope " - "(analysis_run_id, scope_kind_code) " - "values (%s, 'analysis_scope_all_visible')", - (run_id,), - ) - with pytest.raises(psycopg2.errors.RaiseException): - cursor.execute( - "update analysis_run_scope set scope_kind_code = scope_kind_code " - "where analysis_run_id = %s", - (run_id,), - ) - with pytest.raises(psycopg2.errors.RaiseException): - cursor.execute( - "delete from analysis_run_scope where analysis_run_id = %s", - (run_id,), - ) - with pytest.raises(psycopg2.errors.RaiseException): - cursor.execute( - "delete from analysis_run where analysis_run_id = %s", - (run_id,), - ) - - -def test_status_requires_scope_and_cannot_predate_request(registry_db) -> None: - """Lifecycle evidence starts only after an immutable authorized request.""" - - with registry_db.cursor() as cursor: - snapshot_id = _insert_snapshot(cursor) - account_id = _insert_account(cursor) - run_id = _insert_run( - cursor, - snapshot_id=snapshot_id, - account_id=account_id, - idempotency_key="scoped-status", - ) - with pytest.raises(psycopg2.errors.RaiseException): - cursor.execute( - "insert into analysis_run_status_event " - "(analysis_run_id, status_ordinal, status_code, occurred_at) " - "values (%s, 1, 'analysis_status_pending', " - "'2026-08-15T01:00:00Z')", - (run_id,), - ) - cursor.execute( - "insert into analysis_run_scope " - "(analysis_run_id, scope_kind_code) " - "values (%s, 'analysis_scope_all_visible')", - (run_id,), - ) - with pytest.raises(psycopg2.errors.RaiseException): - cursor.execute( - "insert into analysis_run_status_event " - "(analysis_run_id, status_ordinal, status_code, occurred_at) " - "values (%s, 1, 'analysis_status_pending', " - "'2026-08-15T00:44:59Z')", - (run_id,), - ) - cursor.execute( - "insert into analysis_run_status_event " - "(analysis_run_id, status_ordinal, status_code, occurred_at, recorded_at) " - "values (%s, 1, 'analysis_status_pending', " - "'2026-08-15T01:00:00Z', '2099-01-01T00:00:00Z') " - "returning recorded_at", - (run_id,), - ) - recorded_at = cursor.fetchone()[0] - assert recorded_at.year < 2099 - - -def test_machine_codes_and_canonical_idempotency_are_fail_closed(registry_db) -> None: - """Audit identifiers are canonical and failure details stay machine-safe.""" - - with registry_db.cursor() as cursor: - snapshot_id = _insert_snapshot(cursor) - account_id = _insert_account(cursor) - with pytest.raises(psycopg2.errors.RaiseException): - _insert_run( - cursor, - snapshot_id=snapshot_id, - account_id=account_id, - idempotency_key="future-request", - requested_at="2099-01-01T00:00:00Z", - ) - with pytest.raises(psycopg2.errors.CheckViolation): - _insert_run( - cursor, - snapshot_id=snapshot_id, - account_id=account_id, - idempotency_key=" padded-key ", - ) - run_id = _insert_run( - cursor, - snapshot_id=snapshot_id, - account_id=account_id, - idempotency_key="machine-safe", - ) - cursor.execute( - "insert into analysis_run_scope " - "(analysis_run_id, scope_kind_code) " - "values (%s, 'analysis_scope_all_visible')", - (run_id,), - ) - cursor.execute( - "insert into analysis_run_status_event " - "(analysis_run_id, status_ordinal, status_code, occurred_at) " - "values (%s, 1, 'analysis_status_pending', " - "'2026-08-15T01:00:00Z')", - (run_id,), - ) - cursor.execute( - "insert into analysis_run_status_event " - "(analysis_run_id, status_ordinal, status_code, occurred_at) " - "values (%s, 2, 'analysis_status_running', " - "'2026-08-15T01:00:00Z')", - (run_id,), - ) - with pytest.raises(psycopg2.errors.CheckViolation): - cursor.execute( - "insert into analysis_run_status_event " - "(analysis_run_id, status_ordinal, status_code, occurred_at, " - "failure_code, retryable) " - "values (%s, 3, 'analysis_status_failed', " - "'2026-08-15T01:00:00Z', 'provider timeout', true)", - (run_id,), - ) - cursor.execute( - "insert into analysis_run_status_event " - "(analysis_run_id, status_ordinal, status_code, occurred_at, " - "failure_code, retryable) " - "values (%s, 3, 'analysis_status_failed', " - "'2026-08-15T01:00:00Z', 'provider_timeout', true)", - (run_id,), - ) -''' - text = text.replace(insertion_anchor, new_tests + insertion_anchor, 1) - path.write_text(text, encoding="utf-8") - - -def apply_implementation() -> None: - """Implement the failing audit, scope, and clock contracts.""" - - migration_path = Path("migrations/0018_analysis_run_registry.sql") - migration = migration_path.read_text(encoding="utf-8") - migration = replace_once( - migration, - " constraint analysis_run_idempotency_key_check\n" - " check (length(btrim(idempotency_key)) between 1 and 256),", - " constraint analysis_run_idempotency_key_check\n" - " check (\n" - " idempotency_key = btrim(idempotency_key)\n" - " and length(idempotency_key) between 1 and 256\n" - " and idempotency_key !~ '[[:cntrl:]]'\n" - " ),", - "canonical idempotency key", - ) - migration = replace_once( - migration, - " constraint analysis_run_configuration_version_check\n" - " check (length(btrim(configuration_schema_version)) between 1 and 128),", - " constraint analysis_run_configuration_version_check\n" - " check (\n" - " configuration_schema_version = btrim(configuration_schema_version)\n" - " and length(configuration_schema_version) between 1 and 128\n" - " ),", - "canonical configuration version", - ) - if migration.count( - "references analysis_run (analysis_run_id) on delete cascade," - ) != 2: - raise SystemExit("analysis-run cascading foreign-key anchors changed") - migration = migration.replace( - "references analysis_run (analysis_run_id) on delete cascade,", - "references analysis_run (analysis_run_id),", - 2, - ) - migration = replace_once( - migration, - " and scope_key is not null\n" - " and length(btrim(scope_key)) between 1 and 256)", - " and scope_key is not null\n" - " and scope_key = btrim(scope_key)\n" - " and length(scope_key) between 1 and 256\n" - " and scope_key !~ '[[:cntrl:]]')", - "canonical thread scope key", - ) - migration = replace_once( - migration, - " and failure_code is not null\n" - " and length(btrim(failure_code)) between 1 and 128)", - " and failure_code is not null\n" - " and failure_code ~ '^[a-z][a-z0-9_]{0,127}$')", - "machine failure code", - ) - migration = replace_once( - migration, - "begin\n" - " select maximum_available_time, captured_at\n", - "begin\n" - " if new.requested_at > clock_timestamp() then\n" - " raise exception 'analysis_run_request_time_in_future';\n" - " end if;\n\n" - " select maximum_available_time, captured_at\n", - "future request rejection", - ) - - old_run_guard = """create or replace function reject_analysis_run_update() -returns trigger -language plpgsql -as $$ -begin - raise exception 'analysis_run_request_is_immutable'; -end -$$; - -comment on function reject_analysis_run_update() is - 'Rejects mutation of actor, scope root, cutoff, or reproducibility digests; ' - 'run progress belongs to append-only status events.'; - -drop trigger if exists analysis_run_update_reject - on analysis_run; -create trigger analysis_run_update_reject -before update on analysis_run -for each row execute function reject_analysis_run_update(); -""" - new_run_guard = """drop trigger if exists analysis_run_update_reject - on analysis_run; -drop trigger if exists analysis_run_mutation_reject - on analysis_run; -drop function if exists reject_analysis_run_update(); - -create or replace function reject_analysis_run_mutation() -returns trigger -language plpgsql -as $$ -begin - raise exception 'analysis_run_request_is_immutable'; -end -$$; - -comment on function reject_analysis_run_mutation() is - 'Rejects update or delete of actor, cutoff, idempotency, and reproducibility ' - 'evidence; run progress belongs to append-only status events.'; - -create trigger analysis_run_mutation_reject -before update or delete on analysis_run -for each row execute function reject_analysis_run_mutation(); - -create or replace function reject_analysis_run_scope_mutation() -returns trigger -language plpgsql -as $$ -begin - raise exception 'analysis_run_scope_is_immutable'; -end -$$; - -comment on function reject_analysis_run_scope_mutation() is - 'Rejects update or delete of the authorization-relevant scope attached to ' - 'an immutable analysis request.'; - -drop trigger if exists analysis_run_scope_mutation_reject - on analysis_run_scope; -create trigger analysis_run_scope_mutation_reject -before update or delete on analysis_run_scope -for each row execute function reject_analysis_run_scope_mutation(); -""" - migration = replace_once( - migration, old_run_guard, new_run_guard, "run and scope mutation guards" - ) - migration = replace_once( - migration, - " previous_occurred_at timestamptz;\n" - "begin\n" - " -- The immutable parent row is a per-run serialization lock. It prevents\n" - " -- concurrent writers from both accepting the same next ordinal.\n" - " perform 1\n" - " from analysis_run\n" - " where analysis_run_id = new.analysis_run_id\n" - " for update;\n\n" - " if not found then\n" - " raise exception 'analysis_run_not_found';\n" - " end if;\n", - " previous_occurred_at timestamptz;\n" - " run_requested_at timestamptz;\n" - "begin\n" - " -- The immutable parent row is a per-run serialization lock. It prevents\n" - " -- concurrent writers from both accepting the same next ordinal.\n" - " select requested_at\n" - " into run_requested_at\n" - " from analysis_run\n" - " where analysis_run_id = new.analysis_run_id\n" - " for update;\n\n" - " if not found then\n" - " raise exception 'analysis_run_not_found';\n" - " end if;\n" - " if not exists (\n" - " select 1 from analysis_run_scope\n" - " where analysis_run_id = new.analysis_run_id\n" - " ) then\n" - " raise exception 'analysis_run_scope_required';\n" - " end if;\n" - " if new.occurred_at < run_requested_at then\n" - " raise exception 'analysis_run_status_before_request';\n" - " end if;\n" - " new.recorded_at := clock_timestamp();\n", - "scoped lifecycle clock guard", - ) - migration = replace_once( - migration, - "comment on function enforce_analysis_run_status_transition() is\n" - " 'Serializes status appends and enforces pending-first, contiguous ordinals, '\n" - " 'monotonic occurrence time, legal transitions, and terminal finality.';", - "comment on function enforce_analysis_run_status_transition() is\n" - " 'Serializes status appends and requires immutable scope, request-time '\n" - " 'ordering, database-recorded time, legal transitions, and terminal finality.';", - "status transition comment", - ) - migration = replace_once( - migration, - "comment on table analysis_run_scope is\n" - " 'At most one authorization-relevant product scope for an immutable run; '\n" - " 'process-unit ownership remains derivable from process_unit.';", - "comment on table analysis_run_scope is\n" - " 'One immutable authorization-relevant scope is required before lifecycle '\n" - " 'evidence; process-unit ownership remains derivable from process_unit.';", - "scope table comment", - ) - migration_path.write_text(migration, encoding="utf-8") - - rollback_path = Path("migrations/rollback/0018_analysis_run_registry.sql") - rollback = rollback_path.read_text(encoding="utf-8") - rollback = replace_once( - rollback, - "drop function if exists reject_analysis_run_update();\n", - "drop function if exists reject_analysis_run_scope_mutation();\n" - "drop function if exists reject_analysis_run_mutation();\n" - "drop function if exists reject_analysis_run_update();\n", - "rollback mutation functions", - ) - rollback_path.write_text(rollback, encoding="utf-8") - - adr_path = Path("docs/adr/0013-normalized-analysis-run-registry.md") - adr = adr_path.read_text(encoding="utf-8") - adr = replace_once( - adr, - "The analysis request row rejects updates. Lifecycle changes are represented only\n" - "by append-only status events.", - "The analysis request and its authorization scope reject updates and deletes.\n" - "Lifecycle changes are represented only by append-only status events, so a cascade\n" - "cannot erase the derivation root or its access boundary.", - "ADR immutability", - ) - adr = replace_once( - adr, - "The first event must be `pending`. Failed events require a bounded machine\n" - "failure code; raw exception text is prohibited. `recorded_at` is database system\n" - "time and cannot precede `occurred_at`. `analysis_run_current_status` is a view,\n" - "not a second mutable state authority.", - "The first event must be `pending`, requires an immutable scope, and cannot predate\n" - "the run request. Failed events require a lowercase machine-code identifier; raw\n" - "exception text is prohibited. `recorded_at` is overwritten with database system\n" - "time on every insert and cannot precede `occurred_at`.\n" - "`analysis_run_current_status` is a view, not a second mutable state authority.", - "ADR lifecycle", - ) - adr = replace_once( - adr, - "`analysis_run_scope` stores at most one all-visible, corporate-entity,\n" - "process-unit, or thread-group scope. Its shape is database constrained. The\n" - "next repository/API slice must insert run, scope, and first status in one\n", - "`analysis_run_scope` stores one immutable all-visible, corporate-entity,\n" - "process-unit, or thread-group scope. Its shape is database constrained and the\n" - "first lifecycle event is rejected until it exists. The next repository/API slice\n" - "must insert run, scope, and first status in one\n", - "ADR authorization scope", - ) - adr = replace_once( - adr, - "Every run references a real `user_account`. `requested_by_account_id` is not\n" - "nullable. The idempotency key is unique per authenticated account rather than\n", - "Every run references a real `user_account`. `requested_by_account_id` is not\n" - "nullable. Idempotency keys are trimmed, control-free canonical values and are\n" - "unique per authenticated account rather than\n", - "ADR idempotency", - ) - adr = replace_once( - adr, - "- snapshot, count, and run immutability;\n", - "- snapshot, count, run, and authorization-scope immutability;\n" - "- deletion resistance for request and scope audit evidence;\n" - "- scope-required lifecycle, request-time ordering, and database-owned record time;\n" - "- canonical idempotency and bounded machine-code failure identifiers;\n", - "ADR verification", - ) - adr_path.write_text(adr, encoding="utf-8") - - changelog_path = Path("CHANGELOG.d/milestone2-analysis-run-registry.md") - changelog = changelog_path.read_text(encoding="utf-8") - changelog = replace_once( - changelog, - "- Added account-scoped idempotency, immutable request configuration, serialized\n" - " count/run locking, legal lifecycle transitions, and a derived current-status\n" - " view.", - "- Added canonical account-scoped idempotency, immutable request and scope evidence,\n" - " deletion resistance, serialized count/run locking, scope-required request-time-\n" - " ordered lifecycle transitions, database-owned record time, and a derived\n" - " current-status view.", - "changelog hardening", - ) - changelog_path.write_text(changelog, encoding="utf-8") - - -def main() -> None: - """Dispatch the requested deterministic repair phase.""" - - parser = argparse.ArgumentParser() - parser.add_argument("phase", choices=("add-tests", "apply")) - args = parser.parse_args() - if args.phase == "add-tests": - add_tests() - else: - apply_implementation() - - -if __name__ == "__main__": - main() diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index 35756bce1..f2b38badf 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -155,6 +155,7 @@ def _insert_run( idempotency_key: str, knowledge_cutoff: str = "2026-08-15T00:30:00Z", run_kind_code: str = "analysis_run_lineage", + requested_at: str = "2026-08-15T00:45:00Z", ) -> str: """Insert one immutable account-scoped analysis request.""" @@ -164,8 +165,8 @@ def _insert_run( (analysis_source_snapshot_id, run_kind_code, idempotency_key, requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, - code_revision_sha) - values (%s, %s, %s, %s, %s, 'lineage-run-v1', %s, %s) + code_revision_sha, requested_at) + values (%s, %s, %s, %s, %s, 'lineage-run-v1', %s, %s, %s) returning analysis_run_id """, ( @@ -176,6 +177,7 @@ def _insert_run( knowledge_cutoff, "b" * 64, "c" * 40, + requested_at, ), ) return str(cursor.fetchone()[0]) @@ -209,7 +211,9 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non assert "unique (requested_by_account_id, idempotency_key)" in run_definition assert "enforce_analysis_run_knowledge_cutoff" in migration assert "reject_analysis_source_snapshot_update" in migration - assert "reject_analysis_run_update" in migration + assert "reject_analysis_run_mutation" in migration + assert "reject_analysis_run_scope_mutation" in migration + assert "analysis_run_scope_required" in migration assert "enforce_analysis_source_count_freeze" in migration assert "enforce_analysis_run_status_transition" in migration assert "analysis_run_current_status" in migration @@ -309,6 +313,7 @@ def test_snapshot_supports_multiple_run_owned_cutoffs_and_blocks_future_evidence account_id=second_account_id, idempotency_key="cutoff-two", knowledge_cutoff="2026-08-16T00:00:00Z", + requested_at="2026-08-16T00:30:00Z", ) assert first_run_id != second_run_id with pytest.raises(psycopg2.errors.RaiseException): @@ -444,6 +449,12 @@ def test_status_history_enforces_shape_order_time_and_legal_transitions( account_id=account_id, idempotency_key="first-status", ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code) " + "values (%s, 'analysis_scope_all_visible')", + (first_run_id,), + ) with pytest.raises(psycopg2.errors.RaiseException): cursor.execute( "insert into analysis_run_status_event " @@ -458,6 +469,12 @@ def test_status_history_enforces_shape_order_time_and_legal_transitions( account_id=account_id, idempotency_key="second-status", ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code) " + "values (%s, 'analysis_scope_all_visible')", + (second_run_id,), + ) cursor.execute( "insert into analysis_run_status_event " "(analysis_run_id, status_ordinal, status_code, occurred_at) " @@ -527,6 +544,154 @@ def test_status_history_enforces_shape_order_time_and_legal_transitions( ) + +def test_run_scope_and_request_evidence_are_immutable(registry_db) -> None: + """Authorization scope and request identity cannot be rewritten or erased.""" + + with registry_db.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="immutable-run", + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code) " + "values (%s, 'analysis_scope_all_visible')", + (run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "update analysis_run_scope set scope_kind_code = scope_kind_code " + "where analysis_run_id = %s", + (run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "delete from analysis_run_scope where analysis_run_id = %s", + (run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "delete from analysis_run where analysis_run_id = %s", + (run_id,), + ) + + +def test_status_requires_scope_and_cannot_predate_request(registry_db) -> None: + """Lifecycle evidence starts only after an immutable authorized request.""" + + with registry_db.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="scoped-status", + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z')", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code) " + "values (%s, 'analysis_scope_all_visible')", + (run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T00:44:59Z')", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at, recorded_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z', '2099-01-01T00:00:00Z') " + "returning recorded_at", + (run_id,), + ) + recorded_at = cursor.fetchone()[0] + assert recorded_at.year < 2099 + + +def test_machine_codes_and_canonical_idempotency_are_fail_closed(registry_db) -> None: + """Audit identifiers are canonical and failure details stay machine-safe.""" + + with registry_db.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + with pytest.raises(psycopg2.errors.RaiseException): + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="future-request", + requested_at="2099-01-01T00:00:00Z", + ) + with pytest.raises(psycopg2.errors.CheckViolation): + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key=" padded-key ", + ) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="machine-safe", + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code) " + "values (%s, 'analysis_scope_all_visible')", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z')", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 2, 'analysis_status_running', " + "'2026-08-15T01:00:00Z')", + (run_id,), + ) + with pytest.raises(psycopg2.errors.CheckViolation): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at, " + "failure_code, retryable) " + "values (%s, 3, 'analysis_status_failed', " + "'2026-08-15T01:00:00Z', 'provider timeout', true)", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at, " + "failure_code, retryable) " + "values (%s, 3, 'analysis_status_failed', " + "'2026-08-15T01:00:00Z', 'provider_timeout', true)", + (run_id,), + ) + def test_rollback_refuses_data_loss_then_removes_an_empty_registry(registry_db) -> None: """Downgrade fails closed until audit evidence is explicitly removed.""" From e40f88a1ad1fb41818a47e85036476c8afbe2995 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:52:43 +0900 Subject: [PATCH 16/21] feat: show authorized analysis-run evidence on the home page (v0.79.0) (#95) Port the #77 analysis-run evidence surface onto the #89 registry without a second app or raw source. GET /api/analysis-runs is SQL-scoped; hidden tenant runs 404. After make seed, Demo Corp shows Lineage reconstruction as Succeeded with the synthetic document count. --- ARCHITECTURE.md | 14 + .../0.79.0-analysis-run-authorized-read.md | 9 + CHANGELOG.md | 12 + backend/app/analysis_run_ingestion.py | 190 +++++++++++++ backend/app/main.py | 49 ++++ backend/tests/test_api.py | 152 +++++++++++ docs/adr/0014-authorized-analysis-run-read.md | 47 ++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 39 +++ frontend/src/App.tsx | 55 ++++ frontend/src/api.ts | 24 ++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- scripts/seed_demo_data.py | 101 +++++++ tests/test_analysis_run_authorization.py | 254 ++++++++++++++++++ uv.lock | 2 +- 16 files changed, 950 insertions(+), 4 deletions(-) create mode 100644 CHANGELOG.d/0.79.0-analysis-run-authorized-read.md create mode 100644 backend/app/analysis_run_ingestion.py create mode 100644 docs/adr/0014-authorized-analysis-run-read.md create mode 100644 tests/test_analysis_run_authorization.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 13f0a4599..eba7dd90b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -454,6 +454,20 @@ lists the same dated tickets the period-report members already show. Re-seed is idempotent. The empty-state copy is only for accounts that truly have no dated open tickets. +## Phase 6-M2: authorized analysis-run evidence (read projection) + +Issue #79's first buyer-visible Milestone 2 slice is a source-redacting +read of the #89 registry. `GET /api/analysis-runs` and +`GET /api/analysis-runs/{id}` require `post_read` and apply the scope +in SQL: the requester always sees their own run; a corporate-entity or +process-unit scope is visible only to affiliated accounts; a +thread-group scope is visible only when the account can already see a +post in that group; `all_visible` is requester-only. Hidden runs 404. +The payload is lookup labels plus non-negative aggregate counts -- never +source SQL, a DSN, a raw record, or a provider body. After `make seed`, +Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · +Demo Corp" with "3 documents". + ## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only) First of three staged slices toward the brief's weekly/monthly diff --git a/CHANGELOG.d/0.79.0-analysis-run-authorized-read.md b/CHANGELOG.d/0.79.0-analysis-run-authorized-read.md new file mode 100644 index 000000000..7233020b2 --- /dev/null +++ b/CHANGELOG.d/0.79.0-analysis-run-authorized-read.md @@ -0,0 +1,9 @@ +# 0.79.0 — Authorized analysis-run read projection + +## Added + +- `GET /api/analysis-runs` and `GET /api/analysis-runs/{id}` expose + source-redacting registry evidence to `post_read` accounts. +- Home-page Analysis runs panel shows the seeded Demo Corp lineage run + after `make seed`. Hidden scopes 404. No raw source, DSN, or provider + payload is returned. diff --git a/CHANGELOG.md b/CHANGELOG.md index dc3db16b2..6326e76e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.79.0] - 2026-08-16 + +### Added + +- Authorized analysis-run evidence on the product home page. After + `make seed`, Demo Analyst sees "Lineage reconstruction · Succeeded · + Demo Corp" with the synthetic document count. `GET /api/analysis-runs` + is scoped in SQL: another tenant's run 404s and never appears in the + list. The payload is labels and aggregates -- never source SQL, a DSN, + or a raw record. TEPP stays behind `tepp_client`; Null channels are + unchanged. + ## [0.78.0] - 2026-08-15 ### Changed diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py new file mode 100644 index 000000000..86b75ef89 --- /dev/null +++ b/backend/app/analysis_run_ingestion.py @@ -0,0 +1,190 @@ +"""Authorized, source-redacting reads of the Milestone 2 analysis-run registry. + +The registry itself is issue #89 / migration 0018. This module is the +product projection: an account sees only runs they requested or whose +scope they already have ABAC authority to walk. Aggregate counts and +lookup labels come back; source SQL, DSNs, raw records, and provider +payloads never do. +""" + +from __future__ import annotations + +from typing import Any + +import asyncpg + +from backend.app.knowledge_graph import labels_for_codes + +_VISIBLE_RUN_SQL = """ + run.requested_by_account_id = $1 + or ( + scope.scope_kind_code = 'analysis_scope_corporate_entity' + and scope.corporate_entity_id = any($2::uuid[]) + ) + or ( + scope.scope_kind_code = 'analysis_scope_process_unit' + and exists ( + select 1 from account_affiliation aff + where aff.user_account_id = $1 + and aff.process_unit_id = scope.process_unit_id + ) + ) + or ( + scope.scope_kind_code = 'analysis_scope_thread_group' + and exists ( + select 1 from source_post p + where p.thread_group_key = scope.scope_key + and ( + p.visibility_code = 'public' + or p.corporate_entity_id = any($2::uuid[]) + ) + ) + ) +""" + +_RUN_SELECT = f""" + select + run.analysis_run_id, + run.run_kind_code, + run.knowledge_cutoff, + run.requested_at, + run.configuration_schema_version, + run.configuration_sha256, + run.code_revision_sha, + scope.scope_kind_code, + scope.corporate_entity_id, + corp.entity_name as scope_entity_name, + status.status_code, + status.failure_code + from analysis_run run + join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id + left join analysis_run_current_status status + on status.analysis_run_id = run.analysis_run_id + left join corporate_entity corp + on corp.corporate_entity_id = scope.corporate_entity_id + where {{where}} + order by run.requested_at desc +""" + + +def _iso(value: Any) -> str: + """Serialize a timestamptz the same way post payloads do.""" + return value.isoformat() if hasattr(value, "isoformat") else str(value) + + +async def _counts_by_run( + conn: asyncpg.Connection, + run_ids: list[str], +) -> dict[str, list[asyncpg.Record]]: + """Load aggregate snapshot counts for the given runs.""" + if not run_ids: + return {} + rows = await conn.fetch( + """ + select run.analysis_run_id, counts.count_type_code, counts.count_value + from analysis_run run + join analysis_source_count counts + on counts.analysis_source_snapshot_id = run.analysis_source_snapshot_id + where run.analysis_run_id = any($1::uuid[]) + order by counts.count_type_code + """, + run_ids, + ) + grouped: dict[str, list[asyncpg.Record]] = {} + for row in rows: + grouped.setdefault(str(row["analysis_run_id"]), []).append(row) + return grouped + + +async def _serialize_runs( + conn: asyncpg.Connection, + rows: list[asyncpg.Record], +) -> list[dict[str, Any]]: + """Project registry rows into the authorized buyer-facing payload.""" + if not rows: + return [] + count_rows = await _counts_by_run(conn, [str(row["analysis_run_id"]) for row in rows]) + labels = await labels_for_codes( + conn, + [row["run_kind_code"] for row in rows] + + [row["scope_kind_code"] for row in rows] + + [row["status_code"] for row in rows if row["status_code"]] + + [ + count["count_type_code"] + for counts in count_rows.values() + for count in counts + ], + ) + payload: list[dict[str, Any]] = [] + for row in rows: + run_id = str(row["analysis_run_id"]) + kind = row["run_kind_code"] + scope = row["scope_kind_code"] + status = row["status_code"] + item: dict[str, Any] = { + "analysis_run_id": run_id, + "run_kind_code": kind, + "run_kind_label": labels.get(kind, kind), + "scope_kind_code": scope, + "scope_kind_label": labels.get(scope, scope), + "status_code": status, + "status_label": labels.get(status, status) if status else None, + "knowledge_cutoff": _iso(row["knowledge_cutoff"]), + "requested_at": _iso(row["requested_at"]), + "source_counts": [ + { + "count_type_code": count["count_type_code"], + "count_type_label": labels.get( + count["count_type_code"], count["count_type_code"] + ), + "count_value": int(count["count_value"]), + } + for count in count_rows.get(run_id, []) + ], + } + if row["scope_entity_name"]: + item["scope_entity_name"] = row["scope_entity_name"] + payload.append(item) + return payload + + +async def fetch_visible_analysis_runs( + conn: asyncpg.Connection, + account_id: str, + affiliated_entity_ids: list[str], +) -> list[dict[str, Any]]: + """Runs the account requested or whose scope they may already walk.""" + rows = await conn.fetch( + _RUN_SELECT.format(where=_VISIBLE_RUN_SQL), + account_id, + affiliated_entity_ids, + ) + return await _serialize_runs(conn, rows) + + +async def fetch_visible_analysis_run( + conn: asyncpg.Connection, + analysis_run_id: str, + account_id: str, + affiliated_entity_ids: list[str], +) -> dict[str, Any] | None: + """One visible run, or None when it is missing or hidden.""" + rows = await conn.fetch( + _RUN_SELECT.format( + where=f"run.analysis_run_id = $3 and ({_VISIBLE_RUN_SQL})" + ), + account_id, + affiliated_entity_ids, + analysis_run_id, + ) + payload = await _serialize_runs(conn, rows) + if not payload: + return None + detail = payload[0] + row = rows[0] + detail["configuration_schema_version"] = row["configuration_schema_version"] + detail["configuration_sha256"] = row["configuration_sha256"] + detail["code_revision_sha"] = row["code_revision_sha"] + if row["failure_code"]: + detail["failure_code"] = row["failure_code"] + return detail diff --git a/backend/app/main.py b/backend/app/main.py index c0214f0c0..81630d35b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,6 +22,7 @@ import asyncio from contextlib import asynccontextmanager from typing import Any +from uuid import UUID import asyncpg import redis.asyncio as redis @@ -64,6 +65,10 @@ from lineageweave.post_summary import ContextualOrchestratorPostSummaryClient, NullPostSummaryClient from lineageweave.relation_verification import NullRelationVerificationClient, SearxngRelationVerificationClient +from backend.app.analysis_run_ingestion import ( + fetch_visible_analysis_run, + fetch_visible_analysis_runs, +) from backend.app.activity_stream import ( create_valkey_client, get_valkey, @@ -1148,6 +1153,50 @@ async def derive_post_commitment( return {"post_id": str(post["post_id"]), "has_commitment": True, "ticket": ticket} +@app.get("/api/analysis-runs") +async def list_analysis_runs( + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Authorized analysis-run list: aggregates and labels only. + + Hidden scopes 404 at the item path and never appear here. The + payload has no source SQL, DSN, raw record, or provider body. + """ + _require_post_read(account) + async with pool.acquire() as conn: + runs = await fetch_visible_analysis_runs( + conn, + account.user_account_id, + list(account.corporate_entity_ids), + ) + return {"analysis_runs": runs} + + +@app.get("/api/analysis-runs/{analysis_run_id}") +async def read_analysis_run( + analysis_run_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """One authorized analysis-run projection, or 404 when hidden.""" + _require_post_read(account) + try: + UUID(analysis_run_id) + except ValueError: + raise HTTPException(status.HTTP_404_NOT_FOUND, "analysis run not found") from None + async with pool.acquire() as conn: + run = await fetch_visible_analysis_run( + conn, + analysis_run_id, + account.user_account_id, + list(account.corporate_entity_ids), + ) + if run is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "analysis run not found") + return run + + @app.get("/api/calendar") async def read_calendar( account: CurrentAccount = Depends(get_current_account), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 62c32bcf0..3a6bab603 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -30,6 +30,7 @@ _VALKEY_URL = os.environ.get("LINEAGEWEAVE_TEST_VALKEY_URL", "redis://localhost:16379/0") _REALM = "lineageweave-demo" _MIGRATION_PATH = Path(__file__).resolve().parents[2] / "migrations" / "0001_initial_schema.sql" +_REGISTRY_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0018_analysis_run_registry.sql" def _postgres_available() -> bool: @@ -112,6 +113,7 @@ def seeded_db(demo_analyst_token): try: with conn.cursor() as cur: cur.execute(_MIGRATION_PATH.read_text()) + cur.execute(_REGISTRY_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -187,6 +189,108 @@ def seeded_db(demo_analyst_token): "insert into role_permission (access_role_id, permission_code) values (%s, 'post_read')", (role_id,), ) + + def _seed_analysis_run( + digest: str, + idempotency_key: str, + requester_id, + scope_kind: str, + corp_id=None, + ) -> str: + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'source-contract-v1', + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + (digest,), + ) + snapshot_id = cur.fetchone()[0] + cur.execute( + """ + insert into analysis_source_count + (analysis_source_snapshot_id, count_type_code, count_value) + values (%s, 'analysis_count_document', 3) + """, + (snapshot_id,), + ) + cur.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, 'analysis_run_lineage', %s, %s, + '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, + '2026-01-12T12:30:00Z') + returning analysis_run_id + """, + (snapshot_id, idempotency_key, requester_id, "b" * 64, "c" * 40), + ) + run_id = str(cur.fetchone()[0]) + if scope_kind == "analysis_scope_corporate_entity": + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, corporate_entity_id) + values (%s, %s, %s) + """, + (run_id, scope_kind, corp_id), + ) + else: + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code) + values (%s, %s) + """, + (run_id, scope_kind), + ) + for ordinal, status, occurred in ( + (1, "analysis_status_pending", "2026-01-12T12:31:00Z"), + (2, "analysis_status_running", "2026-01-12T12:32:00Z"), + (3, "analysis_status_succeeded", "2026-01-12T12:33:00Z"), + ): + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values (%s, %s, %s, %s) + """, + (run_id, ordinal, status, occurred), + ) + return run_id + + cur.execute( + "insert into user_account (external_subject_id, display_name, email_address) " + "values (%s, 'Other Analyst', 'other.analyst@example.test') returning user_account_id", + (f"other-{uuid.uuid4()}",), + ) + other_account_id = cur.fetchone()[0] + visible_run_id = _seed_analysis_run( + "a" * 64, + "visible-own-corp", + account_id, + "analysis_scope_corporate_entity", + own_corp_id, + ) + hidden_run_id = _seed_analysis_run( + "d" * 64, + "hidden-other-corp", + other_account_id, + "analysis_scope_corporate_entity", + other_corp_id, + ) + hidden_all_visible_id = _seed_analysis_run( + "e" * 64, + "hidden-all-visible", + other_account_id, + "analysis_scope_all_visible", + ) cur.execute( "insert into account_role_assignment (user_account_id, access_role_id) values (%s, %s)", (account_id, role_id), @@ -298,6 +402,9 @@ def _insert_post(title: str, corporate_entity_id, visibility_code: str, body: st "our_person_id": our_person_id, "counterpart_person_id": counterpart_person_id, "hidden_person_id": hidden_person_id, + "visible_run_id": visible_run_id, + "hidden_run_id": hidden_run_id, + "hidden_all_visible_id": hidden_all_visible_id, } finally: conn.close() @@ -320,6 +427,51 @@ def client(seeded_db): yield test_client +def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( + client, demo_analyst_token, seeded_db +) -> None: + """Demo analyst sees the Test Corp run, never the Other Corp or outsider run.""" + listed = client.get("/api/analysis-runs", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert listed.status_code == 200 + runs = listed.json()["analysis_runs"] + ids = {run["analysis_run_id"] for run in runs} + assert seeded_db["visible_run_id"] in ids + assert seeded_db["hidden_run_id"] not in ids + assert seeded_db["hidden_all_visible_id"] not in ids + visible = next(run for run in runs if run["analysis_run_id"] == seeded_db["visible_run_id"]) + assert visible["run_kind_label"] == "Lineage reconstruction" + assert visible["status_label"] == "Succeeded" + assert visible["scope_kind_label"] == "Corporate entity" + assert visible["scope_entity_name"] == "Test Corp" + assert visible["source_counts"] == [ + { + "count_type_code": "analysis_count_document", + "count_type_label": "Documents", + "count_value": 3, + } + ] + dumped = str(visible) + assert "postgresql://" not in dumped + assert "select " not in dumped.lower() + + detail = client.get( + f"/api/analysis-runs/{seeded_db['visible_run_id']}", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert detail.status_code == 200 + assert detail.json()["configuration_schema_version"] == "lineage-run-v1" + assert "snapshot_sha256" not in detail.json() + + hidden = client.get( + f"/api/analysis-runs/{seeded_db['hidden_run_id']}", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert hidden.status_code == 404 + + unauthenticated = client.get("/api/analysis-runs") + assert unauthenticated.status_code == 401 + + def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> None: response = client.get("/api/me", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md new file mode 100644 index 000000000..0621614d3 --- /dev/null +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -0,0 +1,47 @@ +# ADR 0014 — Analysis-run evidence is an authorized, source-redacting read + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-16 +**Depends on:** ADR 0013 normalized analysis-run registry +**Refs:** Issue #79 (Milestone 2 parent); closed PR #77 is read-only evidence + +## Context + +PR #89 persists analysis-run identity, aggregate reconciliation, scope, +and lifecycle without exposing a product API. Buyers still cannot see +whether a lineage reconstruction ran, succeeded, or reconciled how many +documents. Closed PR #77 exposed analysis records through a parallel +application that also stored raw metadata payloads -- that shape cannot +become protected product truth. + +## Decision + +LineageWeave owns a fail-closed read projection of the #89 registry: + +- `GET /api/analysis-runs` and `GET /api/analysis-runs/{id}` require + `post_read`. +- Visibility is evaluated in SQL. A run is visible when the caller + requested it, or the scope is a corporate entity / process unit / + thread group the caller may already walk. `all_visible` stays + requester-only so it cannot broaden another tenant's evidence. +- Hidden runs return 404, not 403, and never appear in the list. +- The payload carries lookup labels and non-negative aggregate counts. + It does not carry source SQL, DSNs, raw records, image bytes, provider + payloads, credentials, or another service's table names. +- TEPP remains a versioned `AnalysisRunRequest` consumer + (`lineageweave.tepp_client`). This slice does not fork TEPP arithmetic. +- contextual-orchestrator remains the only LLM path. This slice does not + call a raw model API. + +## Consequences + +`make seed` writes one synthetic Demo Corp lineage run so the existing +React home page can show Analysis runs without a second application. +Write/rebuild APIs, TEPP submission, and an Analysis Run Console remain +later slices. + +## References + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV +ontology* (W3C Recommendation). World Wide Web Consortium. +https://www.w3.org/TR/2013/REC-prov-o-20130430/ diff --git a/frontend/package.json b/frontend/package.json index eef0c8735..cde22610e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.78.0", + "version": "0.79.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 85346bc88..f7e89a3d3 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -169,6 +169,33 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", has_commitment: true, ticket }), ); } + if (url.endsWith("/api/analysis-runs")) { + return Promise.resolve( + jsonResponse({ + analysis_runs: [ + { + analysis_run_id: "run-demo-lineage", + run_kind_code: "analysis_run_lineage", + run_kind_label: "Lineage reconstruction", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_succeeded", + status_label: "Succeeded", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:30:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + }, + ], + }), + ); + } if (url.endsWith("/api/calendar")) { return Promise.resolve( jsonResponse({ @@ -1296,6 +1323,18 @@ describe("App, authenticated", () => { await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); }); + it("shows the seeded analysis run on the home page", async () => { + stubBackend(); + render(); + + expect(await screen.findByRole("heading", { name: "Analysis runs" })).toBeInTheDocument(); + const list = screen.getByRole("list", { name: "Analysis runs" }); + expect(list).toHaveTextContent("Lineage reconstruction · Succeeded · Demo Corp"); + expect(list).toHaveTextContent("3 documents"); + expect(list).not.toHaveTextContent("postgresql://"); + expect(list).not.toHaveTextContent("select "); + }); + it("shows the calibrated period-report mean theta on the home page", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5a9130f92..67cd99a9d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { deriveCommitment, evaluatePost, extractPostKeymen, + fetchAnalysisRuns, fetchCalendar, fetchLineageGraph, fetchMe, @@ -33,6 +34,7 @@ import { verifyPostRelations, type ActivityEvent, type AffiliateNode, + type AnalysisRun, type CalendarEntry, type ChatAnswer, type ChatExchange, @@ -1344,6 +1346,58 @@ function PostDetailPopup({ ); } +function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { + const [runs, setRuns] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetchAnalysisRuns(accessToken) + .then((payload) => setRuns(payload.analysis_runs)) + .catch((err) => setError(String(err))); + }, [accessToken]); + + if (error) return

{error}

; + if (runs === null) return

Loading analysis runs...

; + + return ( +
+
+

Analysis runs

+
+ {runs.length === 0 ? ( +

+ No analysis runs visible to this account yet -- try `make seed`. +

+ ) : ( +
    + {runs.map((run) => { + const documentCount = run.source_counts.find( + (count) => count.count_type_code === "analysis_count_document", + ); + const caption = [ + run.run_kind_label, + run.status_label, + run.scope_entity_name ?? run.scope_kind_label, + ] + .filter(Boolean) + .join(" · "); + return ( +
  • + {caption} + {documentCount && ( + + {documentCount.count_value} {documentCount.count_type_label.toLowerCase()} + + )} +
  • + ); + })} +
+ )} +
+ ); +} + function CalendarPanel({ accessToken, onSelectPost, @@ -1632,6 +1686,7 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> +
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2f3e3bbb6..91e9f65e4 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -492,3 +492,27 @@ export function deriveCommitment(accessToken: string, postId: string): Promise { return backendFetch("/api/calendar", accessToken); } + +export interface AnalysisRunCount { + count_type_code: string; + count_type_label: string; + count_value: number; +} + +export interface AnalysisRun { + analysis_run_id: string; + run_kind_code: string; + run_kind_label: string; + scope_kind_code: string; + scope_kind_label: string; + scope_entity_name?: string; + status_code: string | null; + status_label: string | null; + knowledge_cutoff: string; + requested_at: string; + source_counts: AnalysisRunCount[]; +} + +export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { + return backendFetch("/api/analysis-runs", accessToken); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index be9228d75..a8f40a3cc 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.78.0" +__version__ = "0.79.0" diff --git a/pyproject.toml b/pyproject.toml index fe1ad488d..9f9ed8537 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.78.0" +version = "0.79.0" 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 806014e27..dec4de9ad 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -111,6 +111,7 @@ def seed( cur.execute((migrations / "0014_role_responsibility_team_actor_type.sql").read_text()) cur.execute((migrations / "0015_organization_name_resolution.sql").read_text()) cur.execute((migrations / "0016_cross_post_actor_identity.sql").read_text()) + cur.execute((migrations / "0018_analysis_run_registry.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values @@ -322,6 +323,11 @@ def seed( corporate_entity_id, process_units["DEMO-PU-LINEAGE"], ) + _seed_demo_analysis_run( + cur, + account_ids["demo.analyst"], + corporate_entity_id, + ) conn.commit() finally: @@ -1192,6 +1198,101 @@ def _seed_demo_period_report(cur, author_account_id, corporate_entity_id, proces _persist_seed_period_report(cur, "process_unit", high_key, w03, week3[high_key]) +def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None: + """Insert one Demo-Corp lineage run so Analysis runs is not empty. + + Aggregates only: three synthetic documents, one thread. The digest is + a hash of a fixed demo contract string -- never a source row or DSN. + """ + import hashlib + + digest = hashlib.sha256(b"lineageweave-synthetic-demo-snapshot-v1").hexdigest() + cur.execute( + "select analysis_source_snapshot_id from analysis_source_snapshot " + "where snapshot_sha256 = %s", + (digest,), + ) + snapshot_row = cur.fetchone() + if snapshot_row is None: + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'demo-source-contract-v1', + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + (digest,), + ) + snapshot_id = cur.fetchone()[0] + else: + snapshot_id = snapshot_row[0] + cur.execute( + """ + insert into analysis_source_count + (analysis_source_snapshot_id, count_type_code, count_value) + values + (%s, 'analysis_count_document', 3), + (%s, 'analysis_count_thread', 1), + (%s, 'analysis_count_lineage_node', 5), + (%s, 'analysis_count_lineage_edge', 4) + on conflict do nothing + """, + (snapshot_id, snapshot_id, snapshot_id, snapshot_id), + ) + cur.execute( + """ + select analysis_run_id from analysis_run + where requested_by_account_id = %s + and idempotency_key = 'demo-lineage-seed-2026-w02' + """, + (requested_by_account_id,), + ) + run_row = cur.fetchone() + if run_row is None: + cur.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, 'analysis_run_lineage', 'demo-lineage-seed-2026-w02', + %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, + '2026-01-12T12:30:00Z') + returning analysis_run_id + """, + (snapshot_id, requested_by_account_id, "b" * 64, "c" * 40), + ) + run_id = cur.fetchone()[0] + else: + run_id = run_row[0] + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, corporate_entity_id) + values (%s, 'analysis_scope_corporate_entity', %s) + on conflict (analysis_run_id) do nothing + """, + (run_id, corporate_entity_id), + ) + for ordinal, status, occurred in ( + (1, "analysis_status_pending", "2026-01-12T12:31:00Z"), + (2, "analysis_status_running", "2026-01-12T12:32:00Z"), + (3, "analysis_status_succeeded", "2026-01-12T12:33:00Z"), + ): + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values (%s, %s, %s, %s) + on conflict do nothing + """, + (run_id, ordinal, status, occurred), + ) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--postgres-dsn", default=DEFAULT_POSTGRES_DSN) diff --git a/tests/test_analysis_run_authorization.py b/tests/test_analysis_run_authorization.py new file mode 100644 index 000000000..730825c14 --- /dev/null +++ b/tests/test_analysis_run_authorization.py @@ -0,0 +1,254 @@ +"""SQL authorization for the Milestone 2 analysis-run read projection.""" + +from __future__ import annotations + +import os +import uuid +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import psycopg2 +import pytest +from psycopg2 import sql + +_ROOT = Path(__file__).resolve().parents[1] +_INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" +_REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" +_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" +) + + +def _postgres_available() -> bool: + """Return whether the configured administrator DSN is reachable.""" + try: + psycopg2.connect(_ADMIN_DSN, connect_timeout=2).close() + return True + except psycopg2.OperationalError: + return False + + +def _database_dsn(database_name: str) -> str: + """Replace only the database path while preserving DSN query options.""" + parsed = urlsplit(_ADMIN_DSN) + return urlunsplit(parsed._replace(path=f"/{database_name}")) + + +@pytest.fixture +def authz_db(): + """Yield a throwaway database migrated through the registry schema.""" + if not _postgres_available(): + pytest.skip("a reachable PostgreSQL administrator DSN is required") + database_name = f"lineageweave_authz_{uuid.uuid4().hex[:12]}" + admin_connection = psycopg2.connect(_ADMIN_DSN) + admin_connection.autocommit = True + with admin_connection.cursor() as cursor: + cursor.execute( + sql.SQL("create database {}").format(sql.Identifier(database_name)) + ) + try: + connection = psycopg2.connect(_database_dsn(database_name)) + try: + connection.autocommit = True + with connection.cursor() as cursor: + cursor.execute(_INITIAL_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) + yield connection + finally: + connection.close() + finally: + with admin_connection.cursor() as cursor: + cursor.execute( + sql.SQL("drop database {}").format(sql.Identifier(database_name)) + ) + admin_connection.close() + + +def _insert_account(cursor, label: str) -> str: + """Insert one synthetic authenticated account and return its UUID.""" + suffix = uuid.uuid4().hex + cursor.execute( + """ + insert into user_account + (external_subject_id, display_name, email_address) + values (%s, %s, %s) + returning user_account_id + """, + (f"{label}-{suffix}", f"{label.title()} User", f"{label}-{suffix}@example.test"), + ) + return str(cursor.fetchone()[0]) + + +def _insert_corp(cursor, code: str, name: str) -> str: + """Insert one synthetic corporate entity.""" + cursor.execute( + """ + insert into common_lookup_value (lookup_category, lookup_code, lookup_label) + values ('corporate_entity_level', 'company', 'Company') + on conflict (lookup_code) do nothing + """ + ) + cursor.execute( + """ + insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) + values (%s, %s, 'company') + returning corporate_entity_id + """, + (code, name), + ) + return str(cursor.fetchone()[0]) + + +def _complete_run( + cursor, + *, + account_id: str, + digest: str, + idempotency_key: str, + scope_kind: str, + corporate_entity_id: str | None = None, +) -> str: + """Insert one succeeded run with one document-count aggregate.""" + cursor.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'source-contract-v1', + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + (digest,), + ) + snapshot_id = cursor.fetchone()[0] + cursor.execute( + """ + insert into analysis_source_count + (analysis_source_snapshot_id, count_type_code, count_value) + values (%s, 'analysis_count_document', 3) + """, + (snapshot_id,), + ) + cursor.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, 'analysis_run_lineage', %s, %s, + '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, + '2026-01-12T12:30:00Z') + returning analysis_run_id + """, + (snapshot_id, idempotency_key, account_id, "b" * 64, "c" * 40), + ) + run_id = str(cursor.fetchone()[0]) + if scope_kind == "analysis_scope_corporate_entity": + cursor.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, corporate_entity_id) + values (%s, %s, %s) + """, + (run_id, scope_kind, corporate_entity_id), + ) + else: + cursor.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code) + values (%s, %s) + """, + (run_id, scope_kind), + ) + for ordinal, status, occurred in ( + (1, "analysis_status_pending", "2026-01-12T12:31:00Z"), + (2, "analysis_status_running", "2026-01-12T12:32:00Z"), + (3, "analysis_status_succeeded", "2026-01-12T12:33:00Z"), + ): + cursor.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values (%s, %s, %s, %s) + """, + (run_id, ordinal, status, occurred), + ) + return run_id + + +def _visible_ids(cursor, account_id: str, entity_ids: list[str]) -> set[str]: + """Apply the same visibility predicate the product API uses.""" + cursor.execute( + """ + select run.analysis_run_id + from analysis_run run + join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id + where + run.requested_by_account_id = %s + or ( + scope.scope_kind_code = 'analysis_scope_corporate_entity' + and scope.corporate_entity_id = any(%s::uuid[]) + ) + or ( + scope.scope_kind_code = 'analysis_scope_process_unit' + and exists ( + select 1 from account_affiliation aff + where aff.user_account_id = %s + and aff.process_unit_id = scope.process_unit_id + ) + ) + """, + (account_id, entity_ids, account_id), + ) + return {str(row[0]) for row in cursor.fetchall()} + + +def test_hidden_scope_does_not_leak_through_all_visible_or_other_corp(authz_db) -> None: + """A Demo-Corp viewer never sees another tenant's run or its aggregates.""" + with authz_db.cursor() as cursor: + viewer = _insert_account(cursor, "viewer") + outsider = _insert_account(cursor, "outsider") + own_corp = _insert_corp(cursor, "DEMO-CORP-AUTHZ", "Demo Corp") + other_corp = _insert_corp(cursor, "OTHER-CORP-AUTHZ", "Other Corp") + cursor.execute( + """ + insert into account_affiliation (user_account_id, corporate_entity_id) + values (%s, %s) + """, + (viewer, own_corp), + ) + own_run = _complete_run( + cursor, + account_id=viewer, + digest="a" * 64, + idempotency_key="own-corp", + scope_kind="analysis_scope_corporate_entity", + corporate_entity_id=own_corp, + ) + hidden_all_visible = _complete_run( + cursor, + account_id=outsider, + digest="d" * 64, + idempotency_key="hidden-all", + scope_kind="analysis_scope_all_visible", + ) + hidden_other_corp = _complete_run( + cursor, + account_id=outsider, + digest="e" * 64, + idempotency_key="hidden-other", + scope_kind="analysis_scope_corporate_entity", + corporate_entity_id=other_corp, + ) + + visible = _visible_ids(cursor, viewer, [own_corp]) + assert own_run in visible + assert hidden_all_visible not in visible + assert hidden_other_corp not in visible + + outsider_visible = _visible_ids(cursor, outsider, [other_corp]) + assert hidden_all_visible in outsider_visible + assert hidden_other_corp in outsider_visible + assert own_run not in outsider_visible diff --git a/uv.lock b/uv.lock index 6d56dde98..6d9094f6a 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.78.0" +version = "0.79.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From dc2517fbe2d1ca1d04c781c630b52600da19fa95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:52:37 +0900 Subject: [PATCH 17/21] feat: open analysis-run detail from the home list (v0.80.0) (#100) Buyer gap: after #95 the home Analysis runs row was inert text. Clicking the seeded Demo Corp lineage run now loads GET /api/analysis-runs/{id} and shows cutoff, requested date, and document count. Hidden runs stay not-visible. Synthetic aggregates only -- never a DSN or source SQL. --- ARCHITECTURE.md | 4 +- .../0.80.0-analysis-run-detail-click.md | 5 ++ CHANGELOG.md | 10 +++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 33 +++++++++ frontend/src/App.tsx | 68 +++++++++++++++---- frontend/src/api.ts | 4 ++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 10 files changed, 113 insertions(+), 19 deletions(-) create mode 100644 CHANGELOG.d/0.80.0-analysis-run-detail-click.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index eba7dd90b..e00b8c8bd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -463,7 +463,9 @@ in SQL: the requester always sees their own run; a corporate-entity or process-unit scope is visible only to affiliated accounts; a thread-group scope is visible only when the account can already see a post in that group; `all_visible` is requester-only. Hidden runs 404. -The payload is lookup labels plus non-negative aggregate counts -- never +The home list is clickable: `GET /api/analysis-runs/{id}` fills a +labeled detail (cutoff, requested date, counts) without exposing a +DSN or raw record. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · Demo Corp" with "3 documents". diff --git a/CHANGELOG.d/0.80.0-analysis-run-detail-click.md b/CHANGELOG.d/0.80.0-analysis-run-detail-click.md new file mode 100644 index 000000000..090ebb824 --- /dev/null +++ b/CHANGELOG.d/0.80.0-analysis-run-detail-click.md @@ -0,0 +1,5 @@ +# 0.80.0 analysis-run detail click + +Home Analysis runs rows open `GET /api/analysis-runs/{id}`. The +detail shows labeled aggregates and dates only. Hidden runs stay +404 / "not visible". Synthetic Demo Corp seed only. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6326e76e6..e93ec6ef8 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.80.0] - 2026-08-16 + +### Added + +- Home Analysis runs rows are buttons. Clicking the seeded Demo Corp + lineage run opens `GET /api/analysis-runs/{id}` and shows cutoff, + requested date, and document count. A hidden run is "This analysis + run is not visible." -- never a raw 404 or a DSN. Still synthetic + aggregates only. + ## [0.79.0] - 2026-08-16 ### Added diff --git a/frontend/package.json b/frontend/package.json index cde22610e..ca3a1810e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.79.0", + "version": "0.80.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f7e89a3d3..ba1285c40 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -169,6 +169,29 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", has_commitment: true, ticket }), ); } + if (url.endsWith("/api/analysis-runs/run-demo-lineage")) { + return Promise.resolve( + jsonResponse({ + analysis_run_id: "run-demo-lineage", + run_kind_code: "analysis_run_lineage", + run_kind_label: "Lineage reconstruction", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_succeeded", + status_label: "Succeeded", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:30:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + }), + ); + } if (url.endsWith("/api/analysis-runs")) { return Promise.resolve( jsonResponse({ @@ -1333,6 +1356,16 @@ describe("App, authenticated", () => { expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); expect(list).not.toHaveTextContent("select "); + + await userEvent.click( + screen.getByRole("button", { + name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp", + }), + ); + expect(await screen.findByRole("heading", { name: "Lineage reconstruction · Succeeded · Demo Corp" })).toBeInTheDocument(); + expect(screen.getByText(/Cutoff 2026-01-12/)).toBeInTheDocument(); + expect(screen.getByText(/Requested 2026-01-12/)).toBeInTheDocument(); + expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); }); it("shows the calibrated period-report mean theta on the home page", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67cd99a9d..8568e9acf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { deriveCommitment, evaluatePost, extractPostKeymen, + fetchAnalysisRun, fetchAnalysisRuns, fetchCalendar, fetchLineageGraph, @@ -1346,8 +1347,15 @@ function PostDetailPopup({ ); } +function analysisRunCaption(run: AnalysisRun): string { + return [run.run_kind_label, run.status_label, run.scope_entity_name ?? run.scope_kind_label] + .filter(Boolean) + .join(" · "); +} + function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { const [runs, setRuns] = useState(null); + const [selected, setSelected] = useState(null); const [error, setError] = useState(null); useEffect(() => { @@ -1356,7 +1364,21 @@ function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { .catch((err) => setError(String(err))); }, [accessToken]); - if (error) return

{error}

; + async function handleOpen(runId: string) { + setError(null); + try { + setSelected(await fetchAnalysisRun(accessToken, runId)); + } catch (err) { + setSelected(null); + if (err instanceof BackendError && err.status === 404) { + setError("This analysis run is not visible."); + return; + } + setError(String(err)); + } + } + + if (error && runs === null) return

{error}

; if (runs === null) return

Loading analysis runs...

; return ( @@ -1364,6 +1386,7 @@ function AnalysisRunsPanel({ accessToken }: { accessToken: string }) {

Analysis runs

+ {error &&

{error}

} {runs.length === 0 ? (

No analysis runs visible to this account yet -- try `make seed`. @@ -1374,26 +1397,43 @@ function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { const documentCount = run.source_counts.find( (count) => count.count_type_code === "analysis_count_document", ); - const caption = [ - run.run_kind_label, - run.status_label, - run.scope_entity_name ?? run.scope_kind_label, - ] - .filter(Boolean) - .join(" · "); + const caption = analysisRunCaption(run); return (

  • - {caption} - {documentCount && ( - - {documentCount.count_value} {documentCount.count_type_label.toLowerCase()} - - )} +
  • ); })} )} + {selected && ( +
    +

    {analysisRunCaption(selected)}

    +

    + Cutoff {selected.knowledge_cutoff.slice(0, 10)} + {" · "} + Requested {selected.requested_at.slice(0, 10)} +

    +
      + {selected.source_counts.map((count) => ( +
    • + {count.count_value} {count.count_type_label.toLowerCase()} +
    • + ))} +
    +
    + )}
    ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 91e9f65e4..bc1c39e6d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -516,3 +516,7 @@ export interface AnalysisRun { export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { return backendFetch("/api/analysis-runs", accessToken); } + +export function fetchAnalysisRun(accessToken: string, analysisRunId: string): Promise { + return backendFetch(`/api/analysis-runs/${analysisRunId}`, accessToken); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index a8f40a3cc..7b561a3ab 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.79.0" +__version__ = "0.80.0" diff --git a/pyproject.toml b/pyproject.toml index 9f9ed8537..57a3973ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.79.0" +version = "0.80.0" 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/uv.lock b/uv.lock index 6d9094f6a..2c009d7e1 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.79.0" +version = "0.80.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From e9bcd4858b0ce73984945722a0068201c925c9dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:07:57 +0900 Subject: [PATCH 18/21] feat: show labeled analysis-run status history (v0.81.0) (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buyer gap: after #100 the detail showed cutoff and counts but not the legal lifecycle the registry already stored. GET /api/analysis-runs/{id} now returns labeled status_history (Pending → Running → Succeeded with occurrence times). The list stays latest-status only. Hidden runs still 404 and never leak events. Failure codes stay machine tokens. Synthetic Demo Corp seed only. --- ARCHITECTURE.md | 9 ++++-- .../0.81.0-analysis-run-status-history.md | 5 ++++ CHANGELOG.md | 10 +++++++ backend/app/analysis_run_ingestion.py | 30 +++++++++++++++++++ backend/app/main.py | 5 +++- backend/tests/test_api.py | 19 ++++++++++-- docs/adr/0014-authorized-analysis-run-read.md | 8 +++-- frontend/package.json | 2 +- frontend/src/App.test.tsx | 24 +++++++++++++++ frontend/src/App.tsx | 10 +++++++ frontend/src/api.ts | 9 ++++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- 13 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 CHANGELOG.d/0.81.0-analysis-run-status-history.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e00b8c8bd..33d9d2c09 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -464,11 +464,14 @@ process-unit scope is visible only to affiliated accounts; a thread-group scope is visible only when the account can already see a post in that group; `all_visible` is requester-only. Hidden runs 404. The home list is clickable: `GET /api/analysis-runs/{id}` fills a -labeled detail (cutoff, requested date, counts) without exposing a -DSN or raw record. The payload is lookup labels plus non-negative aggregate counts -- never +labeled detail (cutoff, requested date, counts, status history) +without exposing a DSN or raw record. Status history is detail-only +and uses lookup labels plus occurrence times; a failure event keeps +its machine `failure_code` rather than an invented caption. The +payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · -Demo Corp" with "3 documents". +Demo Corp" with "3 documents" and Pending / Running / Succeeded times. ## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only) diff --git a/CHANGELOG.d/0.81.0-analysis-run-status-history.md b/CHANGELOG.d/0.81.0-analysis-run-status-history.md new file mode 100644 index 000000000..9fa4b8706 --- /dev/null +++ b/CHANGELOG.d/0.81.0-analysis-run-status-history.md @@ -0,0 +1,5 @@ +# 0.81.0 analysis-run status history + +Detail of `GET /api/analysis-runs/{id}` shows the labeled append-only +lifecycle. The list stays latest-status only. Hidden runs 404. +Synthetic Demo Corp seed only. diff --git a/CHANGELOG.md b/CHANGELOG.md index e93ec6ef8..4c17115a1 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.81.0] - 2026-08-16 + +### Added + +- Analysis-run detail shows the labeled lifecycle: Pending, Running, + then Succeeded, with occurrence times from `analysis_run_status_event`. + The list stays latest-status only. Hidden runs still 404 and never + leak events. Failure codes stay machine tokens -- no invented label. + Synthetic Demo Corp seed only. + ## [0.80.0] - 2026-08-16 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 86b75ef89..b9a09fc09 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -96,6 +96,35 @@ async def _counts_by_run( return grouped +async def _status_history( + conn: asyncpg.Connection, + analysis_run_id: str, +) -> list[dict[str, Any]]: + """Labeled append-only lifecycle for one already-visible run.""" + rows = await conn.fetch( + """ + select status_ordinal, status_code, occurred_at, failure_code + from analysis_run_status_event + where analysis_run_id = $1::uuid + order by status_ordinal + """, + analysis_run_id, + ) + labels = await labels_for_codes(conn, [row["status_code"] for row in rows]) + history: list[dict[str, Any]] = [] + for row in rows: + item: dict[str, Any] = { + "status_ordinal": int(row["status_ordinal"]), + "status_code": row["status_code"], + "status_label": labels.get(row["status_code"], row["status_code"]), + "occurred_at": _iso(row["occurred_at"]), + } + if row["failure_code"]: + item["failure_code"] = row["failure_code"] + history.append(item) + return history + + async def _serialize_runs( conn: asyncpg.Connection, rows: list[asyncpg.Record], @@ -187,4 +216,5 @@ async def fetch_visible_analysis_run( detail["code_revision_sha"] = row["code_revision_sha"] if row["failure_code"]: detail["failure_code"] = row["failure_code"] + detail["status_history"] = await _status_history(conn, analysis_run_id) return detail diff --git a/backend/app/main.py b/backend/app/main.py index 81630d35b..e039a2f58 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1179,7 +1179,10 @@ async def read_analysis_run( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """One authorized analysis-run projection, or 404 when hidden.""" + """One authorized analysis-run projection, or 404 when hidden. + + Detail adds the labeled status history. Hidden runs never leak events. + """ _require_post_read(account) try: UUID(analysis_run_id) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3a6bab603..3104dadd6 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -453,14 +453,29 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( dumped = str(visible) assert "postgresql://" not in dumped assert "select " not in dumped.lower() + assert "status_history" not in visible detail = client.get( f"/api/analysis-runs/{seeded_db['visible_run_id']}", headers={"Authorization": f"Bearer {demo_analyst_token}"}, ) assert detail.status_code == 200 - assert detail.json()["configuration_schema_version"] == "lineage-run-v1" - assert "snapshot_sha256" not in detail.json() + body = detail.json() + assert body["configuration_schema_version"] == "lineage-run-v1" + assert "snapshot_sha256" not in body + history = body["status_history"] + assert [event["status_label"] for event in history] == [ + "Pending", + "Running", + "Succeeded", + ] + assert [event["occurred_at"][:16] for event in history] == [ + "2026-01-12T12:31", + "2026-01-12T12:32", + "2026-01-12T12:33", + ] + assert all("failure_code" not in event for event in history) + assert "postgresql://" not in str(body) hidden = client.get( f"/api/analysis-runs/{seeded_db['hidden_run_id']}", diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 0621614d3..c0f32beef 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -28,6 +28,9 @@ LineageWeave owns a fail-closed read projection of the #89 registry: - The payload carries lookup labels and non-negative aggregate counts. It does not carry source SQL, DSNs, raw records, image bytes, provider payloads, credentials, or another service's table names. +- `GET /api/analysis-runs/{id}` also returns the append-only labeled + `status_history`. The list does not. A failed event may include the + stored machine `failure_code`; this slice does not invent a label. - TEPP remains a versioned `AnalysisRunRequest` consumer (`lineageweave.tepp_client`). This slice does not fork TEPP arithmetic. - contextual-orchestrator remains the only LLM path. This slice does not @@ -37,8 +40,9 @@ LineageWeave owns a fail-closed read projection of the #89 registry: `make seed` writes one synthetic Demo Corp lineage run so the existing React home page can show Analysis runs without a second application. -Write/rebuild APIs, TEPP submission, and an Analysis Run Console remain -later slices. +The detail now shows the legal lifecycle the registry already stored. +Write/rebuild APIs, TEPP submission, and a fuller Analysis Run Console +remain later slices. ## References diff --git a/frontend/package.json b/frontend/package.json index ca3a1810e..aacb6f74d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.80.0", + "version": "0.81.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index ba1285c40..a06e0dc31 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -189,6 +189,26 @@ describe("App, authenticated", () => { count_value: 3, }, ], + status_history: [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:31:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:32:00Z", + }, + { + status_ordinal: 3, + status_code: "analysis_status_succeeded", + status_label: "Succeeded", + occurred_at: "2026-01-12T12:33:00Z", + }, + ], }), ); } @@ -1365,6 +1385,10 @@ describe("App, authenticated", () => { expect(await screen.findByRole("heading", { name: "Lineage reconstruction · Succeeded · Demo Corp" })).toBeInTheDocument(); expect(screen.getByText(/Cutoff 2026-01-12/)).toBeInTheDocument(); expect(screen.getByText(/Requested 2026-01-12/)).toBeInTheDocument(); + const history = screen.getByRole("list", { name: "Analysis run status history" }); + expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); + expect(history).toHaveTextContent("Running 2026-01-12 12:32"); + expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8568e9acf..49b2bf16c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1432,6 +1432,16 @@ function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { ))} + {selected.status_history && selected.status_history.length > 0 && ( +
      + {selected.status_history.map((event) => ( +
    1. + {event.status_label} {event.occurred_at.slice(0, 16).replace("T", " ")} + {event.failure_code ? ` · ${event.failure_code}` : ""} +
    2. + ))} +
    + )} )} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bc1c39e6d..5dea72c0a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -499,6 +499,14 @@ export interface AnalysisRunCount { count_value: number; } +export interface AnalysisRunStatusEvent { + status_ordinal: number; + status_code: string; + status_label: string; + occurred_at: string; + failure_code?: string; +} + export interface AnalysisRun { analysis_run_id: string; run_kind_code: string; @@ -511,6 +519,7 @@ export interface AnalysisRun { knowledge_cutoff: string; requested_at: string; source_counts: AnalysisRunCount[]; + status_history?: AnalysisRunStatusEvent[]; } export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 7b561a3ab..5603c60fe 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.80.0" +__version__ = "0.81.0" diff --git a/pyproject.toml b/pyproject.toml index 57a3973ab..3862da9a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.80.0" +version = "0.81.0" 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" } From 91d5a056261fc626db25829aa7098d57e9a9ba5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:09:38 +0900 Subject: [PATCH 19/21] feat: open visible posts from an analysis-run detail (v0.82.0) (#103) Buyer gap: after #102 the run detail showed history but no way to open a post. Detail now lists ABAC-visible titles in the run's scope. Other-corp private posts stay hidden. List payloads stay aggregates-only. Synthetic titles only. --- ARCHITECTURE.md | 3 +- .../0.82.0-analysis-run-post-clickthrough.md | 4 ++ CHANGELOG.md | 9 +++ backend/app/analysis_run_ingestion.py | 57 +++++++++++++++++++ backend/tests/test_api.py | 4 ++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 5 ++ frontend/src/App.tsx | 25 +++++++- frontend/src/api.ts | 1 + lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 12 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 33d9d2c09..3f3a7cc9a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -462,7 +462,8 @@ read of the #89 registry. `GET /api/analysis-runs` and in SQL: the requester always sees their own run; a corporate-entity or process-unit scope is visible only to affiliated accounts; a thread-group scope is visible only when the account can already see a -post in that group; `all_visible` is requester-only. Hidden runs 404. +post in that group; `all_visible` is requester-only. Hidden runs 404. Detail also lists ABAC-visible post titles in the +run's scope so a buyer can open a post without seeing hidden rows. The home list is clickable: `GET /api/analysis-runs/{id}` fills a labeled detail (cutoff, requested date, counts, status history) without exposing a DSN or raw record. Status history is detail-only diff --git a/CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md b/CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md new file mode 100644 index 000000000..1802a5644 --- /dev/null +++ b/CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md @@ -0,0 +1,4 @@ +# 0.82.0 analysis-run post click-through + +Detail lists ABAC-visible post titles in the run scope. Hidden +other-corp private posts never appear. Synthetic titles only. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c17115a1..8df6fa5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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.82.0] - 2026-08-16 + +### Added + +- Analysis-run detail lists ABAC-visible posts in the run's scope. + After `make seed`, the Demo Corp lineage run opens the Demo public + post. Hidden other-corp private posts never appear. List payloads + stay aggregates-only. + ## [0.81.0] - 2026-08-16 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index b9a09fc09..c18ddf326 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -53,6 +53,8 @@ run.code_revision_sha, scope.scope_kind_code, scope.corporate_entity_id, + scope.process_unit_id, + scope.scope_key, corp.entity_name as scope_entity_name, status.status_code, status.failure_code @@ -217,4 +219,59 @@ async def fetch_visible_analysis_run( if row["failure_code"]: detail["failure_code"] = row["failure_code"] detail["status_history"] = await _status_history(conn, analysis_run_id) + detail["visible_posts"] = await fetch_visible_scope_posts( + conn, + row["scope_kind_code"], + row["corporate_entity_id"], + row["process_unit_id"], + row["scope_key"], + affiliated_entity_ids, + ) return detail + + +async def fetch_visible_scope_posts( + conn: asyncpg.Connection, + scope_kind_code: str, + corporate_entity_id: Any, + process_unit_id: Any, + scope_key: str | None, + affiliated_entity_ids: list[str], +) -> list[dict[str, str]]: + """ABAC-visible post titles in the run's scope -- never a hidden body.""" + if scope_kind_code == "analysis_scope_corporate_entity" and corporate_entity_id: + rows = await conn.fetch( + "select post_id, post_title, visibility_code, corporate_entity_id " + "from source_post where corporate_entity_id = $1 " + "order by created_at, post_title", + corporate_entity_id, + ) + elif scope_kind_code == "analysis_scope_process_unit" and process_unit_id: + rows = await conn.fetch( + "select post_id, post_title, visibility_code, corporate_entity_id " + "from source_post where process_unit_id = $1 " + "order by created_at, post_title", + process_unit_id, + ) + elif scope_kind_code == "analysis_scope_thread_group" and scope_key: + rows = await conn.fetch( + "select post_id, post_title, visibility_code, corporate_entity_id " + "from source_post where thread_group_key = $1 " + "order by created_at, post_title", + scope_key, + ) + elif scope_kind_code == "analysis_scope_all_visible": + rows = await conn.fetch( + "select post_id, post_title, visibility_code, corporate_entity_id " + "from source_post order by created_at, post_title" + ) + else: + return [] + affiliated = {str(entity_id) for entity_id in affiliated_entity_ids} + posts: list[dict[str, str]] = [] + for row in rows: + visible = row["visibility_code"] == "public" or str(row["corporate_entity_id"]) in affiliated + if not visible: + continue + posts.append({"post_id": str(row["post_id"]), "post_title": row["post_title"]}) + return posts diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3104dadd6..cfc2a5559 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -475,7 +475,11 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( "2026-01-12T12:33", ] assert all("failure_code" not in event for event in history) + titles = {post["post_title"] for post in body["visible_posts"]} + assert "Own-corp private post" in titles + assert "Other-corp private post" not in titles assert "postgresql://" not in str(body) + assert "visible_posts" not in visible hidden = client.get( f"/api/analysis-runs/{seeded_db['hidden_run_id']}", diff --git a/frontend/package.json b/frontend/package.json index aacb6f74d..5d3f2e2b8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.81.0", + "version": "0.82.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a06e0dc31..4fb1b5649 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -189,6 +189,7 @@ describe("App, authenticated", () => { count_value: 3, }, ], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], status_history: [ { status_ordinal: 1, @@ -1389,7 +1390,11 @@ describe("App, authenticated", () => { expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); expect(history).toHaveTextContent("Running 2026-01-12 12:32"); expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); + expect(screen.getByRole("button", { name: "Open run post: Public post" })).toBeInTheDocument(); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Open run post: Public post" })); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); }); it("shows the calibrated period-report mean theta on the home page", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49b2bf16c..1f350928d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1353,7 +1353,13 @@ function analysisRunCaption(run: AnalysisRun): string { .join(" · "); } -function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { +function AnalysisRunsPanel({ + accessToken, + onSelectPost, +}: { + accessToken: string; + onSelectPost: (postId: string) => void; +}) { const [runs, setRuns] = useState(null); const [selected, setSelected] = useState(null); const [error, setError] = useState(null); @@ -1442,6 +1448,21 @@ function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { ))} )} + {selected.visible_posts && selected.visible_posts.length > 0 && ( +
      + {selected.visible_posts.map((post) => ( +
    • + +
    • + ))} +
    + )} )} @@ -1736,7 +1757,7 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> - +
    diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5dea72c0a..3dacb054c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -520,6 +520,7 @@ export interface AnalysisRun { requested_at: string; source_counts: AnalysisRunCount[]; status_history?: AnalysisRunStatusEvent[]; + visible_posts?: { post_id: string; post_title: string }[]; } export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5603c60fe..b1f0c97b1 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.81.0" +__version__ = "0.82.0" diff --git a/pyproject.toml b/pyproject.toml index 3862da9a0..9238d87a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.81.0" +version = "0.82.0" 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/uv.lock b/uv.lock index 2c009d7e1..f3307cb32 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.80.0" +version = "0.82.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From fdab35e74845b22ef4249efd5438a28b73c40402 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:19:40 +0000 Subject: [PATCH 20/21] docs(adr): keep registry ADR 0013 after #74 reused the number PR #91 landed an adaptive-orchestration ADR 0013 on the #74 base after this slice already used 0013 for the normalized analysis-run registry. Renumber the adaptive record to 0015 so ADR numbers stay unique. Co-authored-by: Seongho Bae --- ...ault.md => 0015-adaptive-contextual-orchestrator-default.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/adr/{0013-adaptive-contextual-orchestrator-default.md => 0015-adaptive-contextual-orchestrator-default.md} (96%) diff --git a/docs/adr/0013-adaptive-contextual-orchestrator-default.md b/docs/adr/0015-adaptive-contextual-orchestrator-default.md similarity index 96% rename from docs/adr/0013-adaptive-contextual-orchestrator-default.md rename to docs/adr/0015-adaptive-contextual-orchestrator-default.md index ee0402075..433432fbb 100644 --- a/docs/adr/0013-adaptive-contextual-orchestrator-default.md +++ b/docs/adr/0015-adaptive-contextual-orchestrator-default.md @@ -1,4 +1,4 @@ -# ADR-0013: Adaptive contextual-orchestrator mode is the default +# ADR-0015: Adaptive contextual-orchestrator mode is the default - Status: Accepted - Date: 2026-08-16 From 955d0b068d6f18a3697a6ddfa18a1da690ea2204 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:43:46 +0000 Subject: [PATCH 21/21] docs(changelog): point adaptive-orchestration note at ADR 0015 The #74 changelog fold still called that decision ADR 0013. This stack keeps the analysis-run registry as ADR 0013, so the adaptive record is 0015. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df6fa5cc..6ac2df4ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,7 @@ All notable changes to this project are documented here. Format follows and LLM-as-a-Judge consumers now request contextual-orchestrator `auto` mode so the orchestration plane can meet the quality requirement and then minimize known execution cost. Explicit checked `verify` paths remain unchanged - (ADR 0013). + (ADR 0015). ## [0.77.0] - 2026-08-14