diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 36e24332b..e78d36254 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] permissions: contents: read diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b662b00b1..dcf5c8439 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -475,15 +475,19 @@ 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. Failed -list rows add a next-action line (open the run, then connect the +TEPP list rows add a next-action line (open the run, then connect the measurement service) so `tepp_not_available` is not mistaken for a -calibrated negative result. The +calibrated negative result. A failed lineage row tells the operator +to retry reconstruction, not to connect TEPP. 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" and Pending / Running / Succeeded times, and "TEPP measurement · Failed · Demo Corp" whose detail history ends in Failed / `tepp_not_available`. +`POST /api/analysis-runs` records a new Pending lineage run on that +same captured snapshot (ADR 0017). Open Analysis runs, then click +Request lineage reconstruction. TEPP stays a wire client. ## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only) diff --git a/CHANGELOG.d/0.84.0-tepp-analysis-run.md b/CHANGELOG.d/0.84.0-tepp-analysis-run.md index 080cc8240..c96531899 100644 --- a/CHANGELOG.d/0.84.0-tepp-analysis-run.md +++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md @@ -1,6 +1,6 @@ # 0.84.0 TEPP analysis-run seed Seed writes `analysis_run_tepp` via `tepp_client` on the shared Demo -Corp snapshot. The home list shows Failed and the next action; detail -history keeps `tepp_not_available`. Missing transport is not a fake -measurement. +Corp snapshot. The home list shows Failed and a kind-specific next +action; detail history keeps `tepp_not_available`. Missing transport +is not a fake measurement. A failed lineage row does not mention TEPP. diff --git a/CHANGELOG.d/0.85.0-analysis-run-write.md b/CHANGELOG.d/0.85.0-analysis-run-write.md new file mode 100644 index 000000000..95831622f --- /dev/null +++ b/CHANGELOG.d/0.85.0-analysis-run-write.md @@ -0,0 +1,8 @@ +# 0.85.0 Analysis-run write + +`POST /api/analysis-runs` records a pending lineage reconstruction on a +snapshot already bound to the caller's corporate entity (ADR 0017). The +home page button is Request lineage reconstruction. TEPP and period-report +kinds are rejected so this path cannot invent a measurement. A failed +period-report row points at the Reports panel. A pending TEPP corpus hint +does not say the run already measured. diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f4878b4..28e648e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ 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.85.0] - 2026-08-16 + +### Added + +- `POST /api/analysis-runs` records a pending lineage reconstruction on + a snapshot already bound to the caller's corporate entity (ADR 0017). + Open Analysis runs, then click **Request lineage reconstruction**. + The same account/key replays; a drifted cutoff is 409. TEPP and + period-report kinds are rejected so this path cannot invent a + measurement. A missing snapshot tells the operator to ask an + administrator to capture one. + +### Fixed + +- A pending or running TEPP corpus hint no longer says the run already + measured. A failed period-report row tells the operator to rebuild + from the Reports panel. + ## [0.84.0] - 2026-08-16 ### Added @@ -16,7 +34,9 @@ All notable changes to this project are documented here. Format follows keeps `tepp_not_available` -- never a fabricated theta. TEPP stays a wire client, not a local psychometric engine. `make seed` skips snapshot-count inserts once counts exist so a re-run does not hit - the freeze trigger. + the freeze trigger. A failed lineage row tells the operator to retry + reconstruction; only a failed TEPP row mentions the measurement + service. Stacked PRs now run the same GitHub Checks as PRs to main. ## [0.83.0] - 2026-08-16 diff --git a/CLAUDE.md b/CLAUDE.md index 3af72ad45..6ae890f5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ Tool-specific pointer. Policy lives in [AGENTS.md](AGENTS.md) and the ADRs under `docs/adr/`. Do not fork those rules here. -## Analysis-run seed (v0.84.0) +## Analysis-run seed (v0.85.0) `make seed` writes a Demo Corp lineage run and a TEPP run on the same snapshot (ADR 0013). The TEPP path goes through `tepp_client`. A missing @@ -11,4 +11,7 @@ transport or an unused accepted envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`). Do not invent a theta or a local psychometric substitute. The home list caption stays `kind · status · entity`; the machine failure code is detail-only -(ADR 0014). Open the Failed row, then connect a live TEPP transport. +(ADR 0014). Open a Failed TEPP row, then connect a live TEPP +transport. A failed lineage row retries reconstruction -- it does not +mention TEPP. `POST /api/analysis-runs` records Pending lineage only +(ADR 0017); it does not invent a TEPP theta. diff --git a/backend/app/analysis_run_write.py b/backend/app/analysis_run_write.py new file mode 100644 index 000000000..0af92024c --- /dev/null +++ b/backend/app/analysis_run_write.py @@ -0,0 +1,372 @@ +"""Atomic analysis-run write: snapshot reuse, run, scope, first pending event. + +ADR 0013 follow-up 1 / ADR 0017. This module creates a lineage request +against an already-captured snapshot. It does not invent a TEPP theta, +create a local psychometric substitute, or persist source SQL, DSNs, +raw posts, or provider bodies. + +Idempotency is account-scoped. A retry with the same key and the same +request digest returns the existing run. A retry that names different +evidence or configuration is a conflict. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +import asyncpg +from asyncpg.exceptions import UniqueViolationError + +from lineageweave import __version__ as PACKAGE_VERSION + +LINEAGE_RUN_KIND = "analysis_run_lineage" +TEPP_RUN_KIND = "analysis_run_tepp" +REPORT_RUN_KIND = "analysis_run_report" +CORPORATE_SCOPE_KIND = "analysis_scope_corporate_entity" +PENDING_STATUS = "analysis_status_pending" +LINEAGE_SCHEMA_VERSION = "lineage-run-v1" +_IDEMPOTENCY_KEY = re.compile(r"^[^\x00-\x1f]{1,256}$") +_HEX_DIGEST = re.compile(r"^[0-9a-f]{64}$") + + +class AnalysisRunWriteError(Exception): + """Base class for fail-closed write outcomes the API can map.""" + + +class AnalysisRunNotAllowed(AnalysisRunWriteError): + """The requested kind is not created by this endpoint.""" + + +class AnalysisRunForbiddenScope(AnalysisRunWriteError): + """The caller asked for a corporate entity they may not walk.""" + + +class AnalysisRunSnapshotMissing(AnalysisRunWriteError): + """No captured snapshot is bound to this corporate entity yet.""" + + +class AnalysisRunConflict(AnalysisRunWriteError): + """The same account/key already names a different reconstruction.""" + + def __init__(self, analysis_run_id: str) -> None: + super().__init__("idempotency key already names a different reconstruction") + self.analysis_run_id = analysis_run_id + + +class AnalysisRunInvalidRequest(AnalysisRunWriteError): + """The request key, cutoff, or identifier is not canonical.""" + + +@dataclass(frozen=True) +class AnalysisRunWriteResult: + """One created or replayed pending lineage run.""" + + analysis_run_id: str + replayed: bool + + +def canonical_idempotency_key(raw: str) -> str: + """Trim and accept a control-free 1..256 character client key. + + The database also enforces ``btrim`` plus no control characters. + Rejecting here keeps the HTTP 422 distinct from a constraint 500. + """ + if not isinstance(raw, str): + raise AnalysisRunInvalidRequest("idempotency_key must be a string") + key = raw.strip() + if not key or len(key) > 256 or not _IDEMPOTENCY_KEY.match(key): + raise AnalysisRunInvalidRequest( + "idempotency_key must be 1..256 trimmed characters without controls" + ) + return key + + +def parse_knowledge_cutoff(raw: str | None, *, requested_at: datetime) -> datetime: + """Parse an optional ISO-8601 cutoff and keep it on or before request time.""" + if raw is None or raw == "": + return requested_at + try: + cutoff = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError as exc: + raise AnalysisRunInvalidRequest( + "knowledge_cutoff must be an ISO-8601 timestamp" + ) from exc + if cutoff.tzinfo is None: + raise AnalysisRunInvalidRequest("knowledge_cutoff must include a timezone") + cutoff = cutoff.astimezone(timezone.utc) + if cutoff > requested_at: + raise AnalysisRunInvalidRequest( + "knowledge_cutoff cannot be later than the request time" + ) + return cutoff + + +def request_configuration_digest( + *, + run_kind_code: str, + scope_kind_code: str, + corporate_entity_id: str, + snapshot_sha256: str, + knowledge_cutoff: datetime, + configuration_schema_version: str, +) -> str: + """SHA-256 of the canonical request the idempotency retry must match.""" + payload = { + "configuration_schema_version": configuration_schema_version, + "corporate_entity_id": corporate_entity_id, + "knowledge_cutoff": knowledge_cutoff.isoformat().replace("+00:00", "Z"), + "run_kind_code": run_kind_code, + "scope_kind_code": scope_kind_code, + "snapshot_sha256": snapshot_sha256, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def code_revision_digest() -> str: + """64-hex digest of this package version -- never a source row.""" + return hashlib.sha256(f"lineageweave-{PACKAGE_VERSION}".encode("utf-8")).hexdigest() + + +def _require_lineage_kind(run_kind_code: str) -> None: + """Reject TEPP and report writes so this slice cannot fake those products.""" + if run_kind_code == TEPP_RUN_KIND: + raise AnalysisRunNotAllowed( + "Connect a TEPP transport from a Failed TEPP row; this endpoint " + "does not invent a measurement." + ) + if run_kind_code == REPORT_RUN_KIND: + raise AnalysisRunNotAllowed( + "Rebuild the period report from the Reports panel." + ) + if run_kind_code != LINEAGE_RUN_KIND: + raise AnalysisRunNotAllowed( + "Only lineage reconstruction can be requested here." + ) + + +def _require_affiliated_entity( + corporate_entity_id: str | None, + affiliated_entity_ids: frozenset[str], +) -> str: + """Resolve the corporate scope the caller may already walk.""" + if corporate_entity_id: + try: + UUID(corporate_entity_id) + except ValueError as exc: + raise AnalysisRunInvalidRequest( + "corporate_entity_id must be a UUID" + ) from exc + if corporate_entity_id not in affiliated_entity_ids: + raise AnalysisRunForbiddenScope( + "corporate entity is not visible to this account" + ) + return corporate_entity_id + if len(affiliated_entity_ids) == 1: + return next(iter(affiliated_entity_ids)) + if not affiliated_entity_ids: + raise AnalysisRunForbiddenScope( + "this account has no corporate entity to reconstruct" + ) + raise AnalysisRunInvalidRequest( + "choose which corporate entity to reconstruct" + ) + + +def _as_utc(value: datetime) -> datetime: + """Normalize a timestamptz or naive UTC value to aware UTC.""" + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +async def _lock_entity_snapshot( + conn: asyncpg.Connection, + corporate_entity_id: str, + not_after: datetime, +) -> asyncpg.Record: + """Lock the latest snapshot already used for this corporate entity. + + A snapshot is an immutable capture (ADR 0013). This write reuses one + that a prior run already bound to the entity so a first-time tenant + cannot attach to another tenant's capture. ``not_after`` is the + request clock: availability must already be knowable. + """ + row = await conn.fetchrow( + """ + select snap.analysis_source_snapshot_id, + snap.snapshot_sha256, + snap.maximum_available_time, + snap.captured_at + from analysis_source_snapshot snap + join analysis_run run + on run.analysis_source_snapshot_id = snap.analysis_source_snapshot_id + join analysis_run_scope scope + on scope.analysis_run_id = run.analysis_run_id + where scope.scope_kind_code = $1 + and scope.corporate_entity_id = $2::uuid + and snap.maximum_available_time <= $3 + and snap.captured_at <= $3 + order by snap.captured_at desc, run.requested_at desc + limit 1 + for update of snap + """, + CORPORATE_SCOPE_KIND, + corporate_entity_id, + not_after, + ) + if row is None: + raise AnalysisRunSnapshotMissing( + "Ask an administrator to capture a source snapshot for this " + "entity, then request reconstruction again." + ) + return row + + +def _same_request( + existing: asyncpg.Record, + *, + snapshot_id: Any, + configuration_sha256: str, + knowledge_cutoff: datetime, +) -> bool: + """True when the stored immutable request matches this retry.""" + return ( + str(existing["analysis_source_snapshot_id"]) == str(snapshot_id) + and existing["run_kind_code"] == LINEAGE_RUN_KIND + and existing["configuration_sha256"] == configuration_sha256 + and _as_utc(existing["knowledge_cutoff"]) == knowledge_cutoff + ) + + +async def create_pending_lineage_run( + conn: asyncpg.Connection, + *, + account_id: str, + affiliated_entity_ids: frozenset[str], + run_kind_code: str, + idempotency_key: str, + corporate_entity_id: str | None = None, + knowledge_cutoff: str | None = None, +) -> AnalysisRunWriteResult: + """Insert run + corporate scope + pending event, or replay the same key. + + The snapshot row is locked first so a concurrent count freeze and + this derivation cannot both commit (ADR 0013 lock order). + """ + _require_lineage_kind(run_kind_code) + key = canonical_idempotency_key(idempotency_key) + entity_id = _require_affiliated_entity(corporate_entity_id, affiliated_entity_ids) + requested_at = datetime.now(timezone.utc) + snapshot = await _lock_entity_snapshot(conn, entity_id, requested_at) + if knowledge_cutoff in (None, ""): + cutoff = _as_utc(snapshot["maximum_available_time"]) + else: + cutoff = parse_knowledge_cutoff(knowledge_cutoff, requested_at=requested_at) + if cutoff < _as_utc(snapshot["maximum_available_time"]): + raise AnalysisRunInvalidRequest( + "knowledge_cutoff cannot precede the snapshot's latest admitted evidence" + ) + digest = request_configuration_digest( + run_kind_code=LINEAGE_RUN_KIND, + scope_kind_code=CORPORATE_SCOPE_KIND, + corporate_entity_id=entity_id, + snapshot_sha256=snapshot["snapshot_sha256"], + knowledge_cutoff=cutoff, + configuration_schema_version=LINEAGE_SCHEMA_VERSION, + ) + if not _HEX_DIGEST.match(digest): + raise AnalysisRunInvalidRequest("configuration digest is not 64 hex characters") + + existing = await conn.fetchrow( + """ + select analysis_run_id, analysis_source_snapshot_id, run_kind_code, + configuration_sha256, knowledge_cutoff + from analysis_run + where requested_by_account_id = $1::uuid + and idempotency_key = $2 + """, + account_id, + key, + ) + if existing is not None: + if _same_request( + existing, + snapshot_id=snapshot["analysis_source_snapshot_id"], + configuration_sha256=digest, + knowledge_cutoff=cutoff, + ): + return AnalysisRunWriteResult(str(existing["analysis_run_id"]), True) + raise AnalysisRunConflict(str(existing["analysis_run_id"])) + + try: + run_id = await conn.fetchval( + """ + 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 ($1, $2, $3, $4::uuid, $5, $6, $7, $8, $9) + returning analysis_run_id + """, + snapshot["analysis_source_snapshot_id"], + LINEAGE_RUN_KIND, + key, + account_id, + cutoff, + LINEAGE_SCHEMA_VERSION, + digest, + code_revision_digest(), + requested_at, + ) + except UniqueViolationError: + raced = await conn.fetchrow( + """ + select analysis_run_id, analysis_source_snapshot_id, run_kind_code, + configuration_sha256, knowledge_cutoff + from analysis_run + where requested_by_account_id = $1::uuid + and idempotency_key = $2 + """, + account_id, + key, + ) + if raced is None: + raise + if _same_request( + raced, + snapshot_id=snapshot["analysis_source_snapshot_id"], + configuration_sha256=digest, + knowledge_cutoff=cutoff, + ): + return AnalysisRunWriteResult(str(raced["analysis_run_id"]), True) + raise AnalysisRunConflict(str(raced["analysis_run_id"])) from None + + await conn.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, corporate_entity_id) + values ($1, $2, $3::uuid) + """, + run_id, + CORPORATE_SCOPE_KIND, + entity_id, + ) + await conn.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values ($1, 1, $2, $3) + """, + run_id, + PENDING_STATUS, + requested_at, + ) + return AnalysisRunWriteResult(str(run_id), False) diff --git a/backend/app/main.py b/backend/app/main.py index e77b173bc..c689d5c4d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -69,6 +69,14 @@ fetch_visible_analysis_run, fetch_visible_analysis_runs, ) +from backend.app.analysis_run_write import ( + AnalysisRunConflict, + AnalysisRunForbiddenScope, + AnalysisRunInvalidRequest, + AnalysisRunNotAllowed, + AnalysisRunSnapshotMissing, + create_pending_lineage_run, +) from backend.app.activity_stream import ( create_valkey_client, get_valkey, @@ -1153,6 +1161,15 @@ async def derive_post_commitment( return {"post_id": str(post["post_id"]), "has_commitment": True, "ticket": ticket} +class CreateAnalysisRunRequest(BaseModel): + """Buyer request for a new lineage reconstruction on a captured snapshot.""" + + run_kind_code: str + idempotency_key: str + corporate_entity_id: str | None = None + knowledge_cutoff: str | None = None + + @app.get("/api/analysis-runs") async def list_analysis_runs( account: CurrentAccount = Depends(get_current_account), @@ -1173,6 +1190,55 @@ async def list_analysis_runs( return {"analysis_runs": runs} +@app.post("/api/analysis-runs") +async def create_analysis_run( + body: CreateAnalysisRunRequest, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Create a pending lineage run, or replay the same account/key. + + TEPP and period-report kinds are rejected so this path cannot invent + a measurement or skip the Reports panel. Hidden corporate scopes 404. + """ + _require_post_read(account) + try: + async with pool.acquire() as conn: + async with conn.transaction(): + created = await create_pending_lineage_run( + conn, + account_id=account.user_account_id, + affiliated_entity_ids=account.corporate_entity_ids, + run_kind_code=body.run_kind_code, + idempotency_key=body.idempotency_key, + corporate_entity_id=body.corporate_entity_id, + knowledge_cutoff=body.knowledge_cutoff, + ) + run = await fetch_visible_analysis_run( + conn, + created.analysis_run_id, + account.user_account_id, + list(account.corporate_entity_ids), + ) + except AnalysisRunNotAllowed as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + except AnalysisRunInvalidRequest as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + except AnalysisRunSnapshotMissing as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + except AnalysisRunForbiddenScope as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc + except AnalysisRunConflict as exc: + raise HTTPException( + status.HTTP_409_CONFLICT, + f"idempotency key already names a different reconstruction ({exc.analysis_run_id})", + ) from exc + if run is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "analysis run not found") + run["replayed"] = created.replayed + return run + + @app.get("/api/analysis-runs/{analysis_run_id}") async def read_analysis_run( analysis_run_id: str, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index df1dfb4a1..9af1afcb4 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -506,6 +506,83 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( assert unauthenticated.status_code == 401 +def test_post_analysis_run_creates_pending_lineage_and_rejects_tepp( + client, demo_analyst_token, seeded_db +) -> None: + """Analysts can request reconstruction; TEPP stays a wire client.""" + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + created = client.post( + "/api/analysis-runs", + headers=headers, + json={ + "run_kind_code": "analysis_run_lineage", + "idempotency_key": "buyer-lineage-1", + "corporate_entity_id": seeded_db["own_corp_id"], + }, + ) + assert created.status_code == 200 + body = created.json() + assert body["run_kind_code"] == "analysis_run_lineage" + assert body["status_code"] == "analysis_status_pending" + assert body["replayed"] is False + assert body["status_history"][0]["status_code"] == "analysis_status_pending" + assert "postgresql://" not in str(body) + assert "theta" not in str(body).casefold() + + replay = client.post( + "/api/analysis-runs", + headers=headers, + json={ + "run_kind_code": "analysis_run_lineage", + "idempotency_key": "buyer-lineage-1", + "corporate_entity_id": seeded_db["own_corp_id"], + }, + ) + assert replay.status_code == 200 + assert replay.json()["analysis_run_id"] == body["analysis_run_id"] + assert replay.json()["replayed"] is True + + drifted = client.post( + "/api/analysis-runs", + headers=headers, + json={ + "run_kind_code": "analysis_run_lineage", + "idempotency_key": "buyer-lineage-1", + "corporate_entity_id": seeded_db["own_corp_id"], + "knowledge_cutoff": "2026-01-12T12:00:00Z", + }, + ) + assert drifted.status_code == 409 + + tepp = client.post( + "/api/analysis-runs", + headers=headers, + json={ + "run_kind_code": "analysis_run_tepp", + "idempotency_key": "buyer-tepp-1", + }, + ) + assert tepp.status_code == 422 + assert "invent a measurement" in tepp.json()["detail"] + + hidden = client.post( + "/api/analysis-runs", + headers=headers, + json={ + "run_kind_code": "analysis_run_lineage", + "idempotency_key": "buyer-other-corp", + "corporate_entity_id": seeded_db["other_corp_id"], + }, + ) + assert hidden.status_code == 404 + + unauthenticated = client.post( + "/api/analysis-runs", + json={"run_kind_code": "analysis_run_lineage", "idempotency_key": "anon"}, + ) + 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/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index 631c07cd2..140075720 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -238,6 +238,9 @@ Acceptance requires: 1. Add a transaction repository that creates snapshot, counts, run, scope, and first status atomically and compares request digests on idempotent retries. + ADR 0017 lands the run+scope+pending write against an already-captured + snapshot bound to the caller's corporate entity. New snapshot+count + materialization from live evidence remains the next increment. 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. diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index dea201bfc..4a4e1102f 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -43,10 +43,13 @@ run on the same snapshot so the existing React home page can show both kinds without a second application. The TEPP run is Failed / `tepp_not_available` when the default transport is missing -- the list keeps that machine code off the caption (this decision) and instead -tells the operator to open the run, then connect the measurement -service. The detail now shows the legal lifecycle the registry already -stored. Write/rebuild APIs, a live TEPP transport, and a fuller -Analysis Run Console remain later slices. +tells the operator to open the TEPP run, then connect the measurement +service. A failed lineage row tells the operator to retry +reconstruction, not to connect TEPP. The detail now shows the legal +lifecycle the registry already stored. ADR 0017 adds +`POST /api/analysis-runs` for a pending lineage request on a captured +snapshot. A live TEPP transport, snapshot materialization from live +evidence, and a fuller Analysis Run Console remain later slices. ## References diff --git a/docs/adr/0017-analysis-run-write.md b/docs/adr/0017-analysis-run-write.md new file mode 100644 index 000000000..91a723b38 --- /dev/null +++ b/docs/adr/0017-analysis-run-write.md @@ -0,0 +1,98 @@ +# ADR 0017 — Operators request a pending lineage run on a captured snapshot + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-16 +**Depends on:** ADR 0013 registry, ADR 0014 authorized read, ADR 0016 cutoff posts +**Refs:** Issue #79 (Milestone 2 parent); ADR 0013 follow-up 1 + +## Context + +ADR 0014 gave buyers a source-redacting list and detail of analysis runs. +After `make seed` they can see a succeeded Demo Corp lineage run and a +Failed TEPP run. They still could not *request* the reconstruction that a +failed lineage row now names. Seed SQL remained the only writer. + +ADR 0013 follow-up 1 asked for a transaction that creates snapshot, counts, +run, scope, and first status atomically and compares request digests on +idempotent retries. Creating a *new* capture from live posts is a later +increment: a snapshot is an immutable evidence bag, not "whatever is in +`source_post` today." This slice reuses a snapshot already bound to the +caller's corporate entity. + +## Decision + +`POST /api/analysis-runs` requires `post_read` and, in one transaction: + +1. locks the latest snapshot already used for a corporate-entity scope the + caller may walk; +2. inserts `analysis_run` + `analysis_run_scope` + the first + `analysis_status_pending` event; +3. returns the same authorized projection as `GET /api/analysis-runs/{id}`, + plus `replayed`. + +```mermaid +sequenceDiagram + participant Operator + participant API + participant Registry + Operator->>API: POST /api/analysis-runs (lineage, idempotency key) + API->>Registry: lock snapshot bound to affiliated corp + alt same account+key+digest + Registry-->>API: existing run + API-->>Operator: 200 replayed=true + else same key, different digest + API-->>Operator: 409 conflict + else new key + Registry->>Registry: run + scope + pending + API-->>Operator: 200 Pending row + end +``` + +Rules: + +- Only `analysis_run_lineage` is accepted. TEPP stays a `tepp_client` + wire path (`tepp_not_available` / `tepp_result_not_persisted`). Period + reports stay on the Reports panel rebuild. +- Hidden corporate entities 404. `all_visible` is not a write scope here. +- An omitted `knowledge_cutoff` uses the snapshot + `maximum_available_time` so a double-submit does not drift the digest. +- Idempotency is account-scoped. Same key + same digest replays. Same key + + different snapshot, cutoff, or kind is 409. +- The payload is labels, clocks, and aggregates. No DSN, SQL, raw post, + image bytes, or provider body. +- Reconstruction execution (outbox / Valkey worker) remains follow-up 3. + This slice records the request as Pending. + +The home page adds **Request lineage reconstruction**. The next action +after a Failed lineage row is that button, not a TEPP connect instruction. + +## Consequences + +- Demo Analyst can request a new Pending Demo Corp lineage run after + `make seed` without inventing a measurement. +- A first-time tenant without a bound snapshot gets 422 and is told to + ask an administrator to capture one. +- Snapshot+count creation from live evidence, TEPP live transport, and + the outbox worker remain later slices. +- Storybook / design-token inventory for repeating list rows waits on + `frontend/mise.toml` Node 24 as the runner (do not add a second Node + toolchain). + +## 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 + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C +Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index a1dc73957..7d7ed1e95 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -12,7 +12,7 @@ | 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. | +| OpenAPI 3.2.0 | Define explicit versioned API schemas rather than exposing database rows or implementation-specific payloads. | `GET`/`POST /api/analysis-runs` and `GET /api/analysis-runs/{id}` are source-redacting list, write, and detail contracts (ADR 0014, ADR 0017). | ## Temporal reasoning @@ -74,6 +74,7 @@ provenance, retention, and immutable evidence rather than blanket masking. | Request identity is stable | Reject analysis-run updates; scope and lifecycle live in their own relations. | | Idempotency is actor-scoped | Permit identical opaque keys for two accounts and reject reuse by the same account. | | Lifecycle is ordered | Require pending first, contiguous ordinals, monotonic time, legal transitions, terminal finality, and append-only rows. | +| Write is lineage-only and idempotent | `POST /api/analysis-runs` creates pending lineage on a bound snapshot; same key+digest replays; TEPP/report kinds 422; hidden corp 404 (ADR 0017). | | Rollback does not erase audit data silently | Reject rollback with any registry rows and allow replay after explicit cleanup. | ## APA 7th references diff --git a/frontend/package.json b/frontend/package.json index c21ed209f..c8f67bc8d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.84.0", + "version": "0.85.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e2a30c684..d0a62f4a5 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -59,6 +59,10 @@ describe("App, authenticated", () => { chatUnavailable?: boolean; searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; + failedLineageRun?: boolean; + succeededTeppRun?: boolean; + pendingTeppRun?: boolean; + failedReportRun?: boolean; }) { const statusLabel: Record = { open: "Open", @@ -80,6 +84,7 @@ describe("App, authenticated", () => { let nextTicketId = 1; const events: { event_id: string; event_type: string; actor_account_id: string; summary: string }[] = []; let nextEventId = 1; + let requestedLineageRun: Record | null = null; const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -178,8 +183,16 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: options?.pendingTeppRun + ? "analysis_status_pending" + : options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.pendingTeppRun + ? "Pending" + : options?.succeededTeppRun + ? "Succeeded" + : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -190,27 +203,40 @@ describe("App, authenticated", () => { }, ], visible_posts: [{ post_id: "post-1", post_title: "Public post" }], - status_history: [ - { - status_ordinal: 1, - status_code: "analysis_status_pending", - status_label: "Pending", - occurred_at: "2026-01-12T12:35:00Z", - }, - { - status_ordinal: 2, - status_code: "analysis_status_running", - status_label: "Running", - occurred_at: "2026-01-12T12:36:00Z", - }, - { - status_ordinal: 3, - status_code: "analysis_status_failed", - status_label: "Failed", - occurred_at: "2026-01-12T12:37:00Z", - failure_code: "tepp_not_available", - }, - ], + status_history: options?.pendingTeppRun + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + ] + : [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + { + status_ordinal: 3, + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", + occurred_at: "2026-01-12T12:37:00Z", + ...(options?.succeededTeppRun + ? {} + : { failure_code: "tepp_not_available" }), + }, + ], }), ); } @@ -223,8 +249,10 @@ describe("App, authenticated", () => { 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", + status_code: options?.failedLineageRun + ? "analysis_status_failed" + : "analysis_status_succeeded", + status_label: options?.failedLineageRun ? "Failed" : "Succeeded", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:30:00Z", source_counts: [ @@ -250,57 +278,113 @@ describe("App, authenticated", () => { }, { status_ordinal: 3, - status_code: "analysis_status_succeeded", - status_label: "Succeeded", + status_code: options?.failedLineageRun + ? "analysis_status_failed" + : "analysis_status_succeeded", + status_label: options?.failedLineageRun ? "Failed" : "Succeeded", occurred_at: "2026-01-12T12:33:00Z", + ...(options?.failedLineageRun + ? { failure_code: "lineage_rebuild_failed" } + : {}), }, ], }), ); } + const analysisRuns: Record[] = [ + { + 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: options?.failedLineageRun + ? "analysis_status_failed" + : "analysis_status_succeeded", + status_label: options?.failedLineageRun ? "Failed" : "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, + }, + ], + }, + { + analysis_run_id: "run-demo-tepp", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: options?.pendingTeppRun + ? "analysis_status_pending" + : options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.pendingTeppRun + ? "Pending" + : options?.succeededTeppRun + ? "Succeeded" + : "Failed", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:34:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + }, + ]; + if (options?.failedReportRun) { + analysisRuns.push({ + analysis_run_id: "run-demo-report", + run_kind_code: "analysis_run_report", + run_kind_label: "Period report", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_failed", + status_label: "Failed", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:40:00Z", + source_counts: [], + }); + } + if (url.endsWith("/api/analysis-runs") && method === "POST") { + requestedLineageRun = { + analysis_run_id: "run-requested-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_pending", + status_label: "Pending", + knowledge_cutoff: "2026-01-12T00:00:00Z", + requested_at: "2026-08-16T15:00:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + replayed: false, + }; + return Promise.resolve(jsonResponse(requestedLineageRun)); + } 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, - }, - ], - }, - { - analysis_run_id: "run-demo-tepp", - run_kind_code: "analysis_run_tepp", - run_kind_label: "TEPP measurement", - scope_kind_code: "analysis_scope_corporate_entity", - scope_kind_label: "Corporate entity", - scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed", - status_label: "Failed", - knowledge_cutoff: "2026-01-12T12:00:00Z", - requested_at: "2026-01-12T12:34:00Z", - source_counts: [ - { - count_type_code: "analysis_count_document", - count_type_label: "Documents", - count_value: 3, - }, - ], - }, - ], + analysis_runs: requestedLineageRun + ? [requestedLineageRun, ...analysisRuns] + : analysisRuns, }), ); } @@ -1479,6 +1563,95 @@ describe("App, authenticated", () => { expect(teppHistory).not.toHaveTextContent("Succeeded"); }); + it("does not tell a failed lineage run to connect the measurement service", async () => { + stubBackend({ failedLineageRun: true }); + render(); + + const list = await screen.findByRole("list", { name: "Analysis runs" }); + expect(list).toHaveTextContent("Lineage reconstruction · Failed · Demo Corp"); + expect(list).toHaveTextContent( + "Open this run to see why it failed, then retry reconstruction from a current snapshot.", + ); + expect(list).toHaveTextContent( + "Open this run to see why it failed, then connect the measurement service and re-run.", + ); + const lineageButton = screen.getByRole("button", { + name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", + }); + expect(lineageButton).not.toHaveTextContent("measurement service"); + }); + + it("does not tell a succeeded TEPP run to replace Failed", async () => { + stubBackend({ succeededTeppRun: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Succeeded · Demo Corp", + }), + ); + expect( + await screen.findByText("These posts are the cutoff corpus this TEPP run measured."), + ).toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); + }); + + it("does not tell a pending TEPP run that it already measured", async () => { + stubBackend({ pendingTeppRun: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Pending · Demo Corp", + }), + ); + expect( + await screen.findByText( + "These posts are the cutoff corpus TEPP will measure after a transport is connected and this run finishes.", + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); + }); + + it("tells a failed period-report run to rebuild from the Reports panel", async () => { + stubBackend({ failedReportRun: true }); + render(); + + const reportButton = await screen.findByRole("button", { + name: "Open analysis run: Period report · Failed · Demo Corp", + }); + expect(reportButton).toHaveTextContent( + "Open this run to see why it failed, then rebuild the period report from the Reports panel.", + ); + expect(reportButton).not.toHaveTextContent("measurement service"); + }); + + it("requests a pending lineage reconstruction from the home list", async () => { + const fetchMock = stubBackend(); + render(); + + await userEvent.click( + await screen.findByRole("button", { name: "Request lineage reconstruction" }), + ); + const list = await screen.findByRole("list", { name: "Analysis runs" }); + expect(list).toHaveTextContent("Lineage reconstruction · Pending · Demo Corp"); + const posted = fetchMock.mock.calls.find((call) => { + const url = String(call[0]); + const init = call[1] as RequestInit | undefined; + return url.endsWith("/api/analysis-runs") && init?.method === "POST"; + }); + expect(posted).toBeDefined(); + if (posted === undefined) { + throw new Error("expected POST /api/analysis-runs"); + } + const body = JSON.parse(String((posted[1] as RequestInit).body)); + expect(body.run_kind_code).toBe("analysis_run_lineage"); + expect(body.idempotency_key).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + }); + 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 50602c687..f1b5daf3e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { useAuth } from "react-oidc-context"; import { askPostChat, BackendError, + createAnalysisRun, createPostTicket, deriveCommitment, evaluatePost, @@ -1356,14 +1357,24 @@ function analysisRunCaption(run: AnalysisRun): string { /** * Next action for a failed run on the home list. * - * The machine `failure_code` stays on detail history (ADR 0014). The - * list tells the operator to open the run, then reconnect the service. + * The machine `failure_code` stays on detail history (ADR 0014). Copy + * is kind-specific so a failed lineage reconstruction is not mistaken + * for a missing TEPP transport. */ function analysisRunNextAction(run: AnalysisRun): string | null { - if (run.status_code === "analysis_status_failed") { - return "Open this run to see why it failed, then connect the measurement service and re-run."; + if (run.status_code !== "analysis_status_failed") { + return null; + } + switch (run.run_kind_code) { + case "analysis_run_tepp": + return "Open this run to see why it failed, then connect the measurement service and re-run."; + case "analysis_run_lineage": + return "Open this run to see why it failed, then retry reconstruction from a current snapshot."; + case "analysis_run_report": + return "Open this run to see why it failed, then rebuild the period report from the Reports panel."; + default: + return "Open this run to see why it failed, then retry after the blocking service is connected."; } - return null; } /** @@ -1389,10 +1400,25 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string { */ function analysisRunCorpusHint(run: AnalysisRun): string | null { if (run.run_kind_code !== "analysis_run_tepp") return null; - return ( - "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + - "transport, then re-run, to replace Failed with a calibrated result." - ); + switch (run.status_code) { + case "analysis_status_failed": + return ( + "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + + "transport, then re-run, to replace Failed with a calibrated result." + ); + case "analysis_status_succeeded": + return "These posts are the cutoff corpus this TEPP run measured."; + case "analysis_status_pending": + case "analysis_status_running": + return ( + "These posts are the cutoff corpus TEPP will measure after a transport " + + "is connected and this run finishes." + ); + case "analysis_status_cancelled": + return "These posts were the cutoff corpus for this cancelled TEPP run."; + default: + return null; + } } function AnalysisRunsPanel({ @@ -1405,6 +1431,7 @@ function AnalysisRunsPanel({ const [runs, setRuns] = useState(null); const [selected, setSelected] = useState(null); const [error, setError] = useState(null); + const [requesting, setRequesting] = useState(false); useEffect(() => { fetchAnalysisRuns(accessToken) @@ -1412,6 +1439,31 @@ function AnalysisRunsPanel({ .catch((err) => setError(String(err))); }, [accessToken]); + async function handleRequestLineage() { + setRequesting(true); + setError(null); + try { + await createAnalysisRun(accessToken, { + run_kind_code: "analysis_run_lineage", + idempotency_key: crypto.randomUUID(), + }); + const payload = await fetchAnalysisRuns(accessToken); + setRuns(payload.analysis_runs); + } catch (err) { + if (err instanceof BackendError && err.status === 409) { + setError( + "This request key already names a different reconstruction. Request again to start a new run.", + ); + } else if (err instanceof BackendError && err.status === 422) { + setError(err.message); + } else { + setError(String(err)); + } + } finally { + setRequesting(false); + } + } + async function handleOpen(runId: string) { setError(null); try { @@ -1435,6 +1487,13 @@ function AnalysisRunsPanel({

Analysis runs

+
{error &&

{error}

} {runs.length === 0 ? ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3dacb054c..3e73965d8 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -530,3 +530,20 @@ export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: export function fetchAnalysisRun(accessToken: string, analysisRunId: string): Promise { return backendFetch(`/api/analysis-runs/${analysisRunId}`, accessToken); } + +export interface CreateAnalysisRunRequest { + run_kind_code: string; + idempotency_key: string; + corporate_entity_id?: string; + knowledge_cutoff?: string; +} + +export function createAnalysisRun( + accessToken: string, + body: CreateAnalysisRunRequest, +): Promise { + return backendFetch("/api/analysis-runs", accessToken, { + method: "POST", + body: JSON.stringify(body), + }); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index e89edfd07..5e05ef4ff 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.84.0" +__version__ = "0.85.0" diff --git a/pyproject.toml b/pyproject.toml index ed229d426..8750ae3e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.84.0" +version = "0.85.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/tests/test_analysis_run_write.py b/tests/test_analysis_run_write.py new file mode 100644 index 000000000..d66819000 --- /dev/null +++ b/tests/test_analysis_run_write.py @@ -0,0 +1,299 @@ +"""Contracts for the atomic analysis-run write (ADR 0017). + +Pure digest/key tests always run. PostgreSQL tests self-skip without a +reachable administrator DSN, matching ``test_analysis_run_registry_schema``. +""" + +from __future__ import annotations + +import asyncio +import os +import uuid +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import asyncpg +import psycopg2 +import pytest +from psycopg2 import sql + +from backend.app.analysis_run_write import ( + LINEAGE_RUN_KIND, + LINEAGE_SCHEMA_VERSION, + AnalysisRunConflict, + AnalysisRunForbiddenScope, + AnalysisRunInvalidRequest, + AnalysisRunNotAllowed, + AnalysisRunSnapshotMissing, + _require_lineage_kind, + canonical_idempotency_key, + code_revision_digest, + create_pending_lineage_run, + parse_knowledge_cutoff, + request_configuration_digest, +) + +_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 test_canonical_idempotency_key_rejects_padding_and_controls() -> None: + """The product key must match the database trim/control contract.""" + assert canonical_idempotency_key(" retry-1 ") == "retry-1" + with pytest.raises(AnalysisRunInvalidRequest): + canonical_idempotency_key(" padded\nkey") + with pytest.raises(AnalysisRunInvalidRequest): + canonical_idempotency_key("") + with pytest.raises(AnalysisRunInvalidRequest): + canonical_idempotency_key("x" * 257) + + +def test_omitted_cutoff_is_stable_across_request_clocks() -> None: + """Two retries a second apart must hash the same default cutoff.""" + first = datetime(2026, 8, 16, 15, 0, tzinfo=timezone.utc) + second = datetime(2026, 8, 16, 15, 0, 1, tzinfo=timezone.utc) + cutoff = datetime(2026, 1, 12, tzinfo=timezone.utc) + left = request_configuration_digest( + run_kind_code=LINEAGE_RUN_KIND, + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="11111111-1111-1111-1111-111111111111", + snapshot_sha256="a" * 64, + knowledge_cutoff=cutoff, + configuration_schema_version=LINEAGE_SCHEMA_VERSION, + ) + right = request_configuration_digest( + run_kind_code=LINEAGE_RUN_KIND, + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="11111111-1111-1111-1111-111111111111", + snapshot_sha256="a" * 64, + knowledge_cutoff=cutoff, + configuration_schema_version=LINEAGE_SCHEMA_VERSION, + ) + assert left == right + assert left != request_configuration_digest( + run_kind_code=LINEAGE_RUN_KIND, + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="11111111-1111-1111-1111-111111111111", + snapshot_sha256="b" * 64, + knowledge_cutoff=cutoff, + configuration_schema_version=LINEAGE_SCHEMA_VERSION, + ) + assert parse_knowledge_cutoff(None, requested_at=first) == first + assert parse_knowledge_cutoff(None, requested_at=second) == second + assert code_revision_digest() == code_revision_digest() + + +def test_tepp_and_report_kinds_are_rejected_without_a_fake_score() -> None: + """This write path must not invent a TEPP theta or skip Reports.""" + with pytest.raises(AnalysisRunNotAllowed, match="does not invent a measurement"): + _require_lineage_kind("analysis_run_tepp") + with pytest.raises(AnalysisRunNotAllowed, match="Reports panel"): + _require_lineage_kind("analysis_run_report") + _require_lineage_kind(LINEAGE_RUN_KIND) + + +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 write_db(): + """Yield a throwaway registry database and its asyncpg DSN.""" + if not _postgres_available(): + pytest.skip("a reachable PostgreSQL administrator DSN is required") + database_name = f"lineageweave_write_{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)) + ) + dsn = _database_dsn(database_name) + try: + connection = psycopg2.connect(dsn) + 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, dsn + 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 _seed_bound_snapshot(cursor) -> tuple[str, str]: + """Insert one account, corp, snapshot, succeeded run, and return ids.""" + cursor.execute( + """ + insert into user_account + (external_subject_id, display_name, email_address) + values (%s, 'Write User', %s) + returning user_account_id + """, + (f"write-{uuid.uuid4().hex}", f"write-{uuid.uuid4().hex}@example.test"), + ) + account_id = str(cursor.fetchone()[0]) + 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, 'Write Corp', 'company') + returning corporate_entity_id + """, + (f"WRITE-{uuid.uuid4().hex[:8]}",), + ) + corp_id = str(cursor.fetchone()[0]) + 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 + """, + ("c" * 64,), + ) + 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', 'seed-write', + %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, + '2026-01-12T12:30:00Z') + returning analysis_run_id + """, + (snapshot_id, account_id, "b" * 64, "d" * 40), + ) + run_id = cursor.fetchone()[0] + cursor.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, corporate_entity_id) + values (%s, 'analysis_scope_corporate_entity', %s) + """, + (run_id, corp_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"), + ): + 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 account_id, corp_id + + +def test_create_pending_run_replays_same_digest_and_conflicts_on_drift(write_db) -> None: + """Same key + same snapshot digest replays; a drifted cutoff conflicts.""" + connection, dsn = write_db + with connection.cursor() as cursor: + account_id, corp_id = _seed_bound_snapshot(cursor) + + async def _exercise() -> None: + conn = await asyncpg.connect(dsn) + try: + first = await create_pending_lineage_run( + conn, + account_id=account_id, + affiliated_entity_ids=frozenset({corp_id}), + run_kind_code=LINEAGE_RUN_KIND, + idempotency_key="buyer-retry-1", + corporate_entity_id=corp_id, + ) + replay = await create_pending_lineage_run( + conn, + account_id=account_id, + affiliated_entity_ids=frozenset({corp_id}), + run_kind_code=LINEAGE_RUN_KIND, + idempotency_key="buyer-retry-1", + corporate_entity_id=corp_id, + ) + assert first.analysis_run_id == replay.analysis_run_id + assert first.replayed is False + assert replay.replayed is True + status = await conn.fetchval( + """ + select status_code from analysis_run_current_status + where analysis_run_id = $1::uuid + """, + first.analysis_run_id, + ) + assert status == "analysis_status_pending" + with pytest.raises(AnalysisRunConflict): + await create_pending_lineage_run( + conn, + account_id=account_id, + affiliated_entity_ids=frozenset({corp_id}), + run_kind_code=LINEAGE_RUN_KIND, + idempotency_key="buyer-retry-1", + corporate_entity_id=corp_id, + knowledge_cutoff="2026-01-12T12:00:00Z", + ) + with pytest.raises(AnalysisRunForbiddenScope): + await create_pending_lineage_run( + conn, + account_id=account_id, + affiliated_entity_ids=frozenset({corp_id}), + run_kind_code=LINEAGE_RUN_KIND, + idempotency_key="other-corp", + corporate_entity_id=str(uuid.uuid4()), + ) + with pytest.raises(AnalysisRunSnapshotMissing): + await create_pending_lineage_run( + conn, + account_id=account_id, + affiliated_entity_ids=frozenset({str(uuid.uuid4())}), + run_kind_code=LINEAGE_RUN_KIND, + idempotency_key="no-snapshot", + ) + finally: + await conn.close() + + asyncio.run(_exercise())