From 34fdb8928a9f615281bea146713655f291180c4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:08:02 +0900 Subject: [PATCH 1/6] feat: seed a TEPP analysis run through tepp_client (v0.84.0) Buyer gap: home Analysis runs only showed lineage reconstruction. make seed now records a Demo Corp TEPP measurement via tepp_client. The default transport is unavailable, so the row is Failed / tepp_not_available -- never a fabricated theta. TEPP stays a wire client, not a local psychometric engine. --- ARCHITECTURE.md | 6 +- CHANGELOG.d/0.84.0-tepp-analysis-run.md | 4 + CHANGELOG.md | 10 ++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 20 ++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- scripts/seed_demo_data.py | 121 ++++++++++++++++++++++++ tests/test_seed_tepp_run.py | 9 ++ uv.lock | 2 +- 10 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.d/0.84.0-tepp-analysis-run.md create mode 100644 tests/test_seed_tepp_run.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bedeba285..362883dfa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -465,7 +465,11 @@ 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. Detail also lists ABAC-visible post titles in the run's scope whose `created_at` is at or before `knowledge_cutoff` (ADR 0016) so a buyer can open a post the run was allowed to know -without seeing later live rows or hidden bodies. +without seeing later live rows or hidden bodies. Detail also returns +revision and configuration digest prefixes. +`make seed` also records a TEPP measurement run through +`tepp_client`; the default transport is unavailable, so that run is +Failed / `tepp_not_available` rather than a fabricated score. 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.84.0-tepp-analysis-run.md b/CHANGELOG.d/0.84.0-tepp-analysis-run.md new file mode 100644 index 000000000..18267d698 --- /dev/null +++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md @@ -0,0 +1,4 @@ +# 0.84.0 TEPP analysis-run seed + +Seed writes `analysis_run_tepp` via `tepp_client`. Missing transport +is Failed / `tepp_not_available`, not a fake measurement. diff --git a/CHANGELOG.md b/CHANGELOG.md index d0edb5d09..b2a57b24a 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.84.0] - 2026-08-16 + +### Added + +- `make seed` records a Demo Corp TEPP measurement run through + `tepp_client`. The default transport is unavailable, so the home + list shows "TEPP measurement · Failed · Demo Corp" with + `tepp_not_available` -- never a fabricated theta. TEPP stays a + wire client, not a local psychometric engine. + ## [0.83.0] - 2026-08-16 ### Fixed diff --git a/frontend/package.json b/frontend/package.json index d1e24268f..c21ed209f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.83.0", + "version": "0.84.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a3da5de6b..4e5fe7da1 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -236,6 +236,25 @@ describe("App, authenticated", () => { }, ], }, + { + 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, + }, + ], + }, ], }), ); @@ -1374,6 +1393,7 @@ describe("App, authenticated", () => { 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("TEPP measurement · Failed · Demo Corp"); expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); expect(list).not.toHaveTextContent("select "); diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5bd7638d6..e89edfd07 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.83.0" +__version__ = "0.84.0" diff --git a/pyproject.toml b/pyproject.toml index f7b33f6ce..ed229d426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.83.0" +version = "0.84.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 cb7c74f87..75a7330f3 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -333,6 +333,11 @@ def seed( account_ids["demo.analyst"], corporate_entity_id, ) + _seed_demo_tepp_run( + cur, + account_ids["demo.analyst"], + corporate_entity_id, + ) conn.commit() finally: @@ -1298,6 +1303,122 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - ) +def tepp_seed_outcome() -> tuple[str, str | None]: + """Ask TEPP through the published client. A missing transport is Failed. + + Never invents a psychometric score. ``tepp_not_available`` means the + channel was dropped, not a calibrated negative result. + """ + from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable + + request = AnalysisRunRequest( + idempotency_key="demo-tepp-seed-2026-w02", + tenant_workspace_id="demo-workspace", + snapshot_id="demo-source-contract-v1", + knowledge_cutoff="2026-01-12T12:00:00Z", + model_contract_version="tepp-analysis-run-v1", + output_profile="calibrated_event_measurement", + ) + try: + TeppClient().submit_analysis_run(request) + except TeppNotAvailable: + return "analysis_status_failed", "tepp_not_available" + return "analysis_status_succeeded", None + + +def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> None: + """Insert one Demo-Corp TEPP run so the kind is visible without a live TEPP. + + Uses :func:`tepp_seed_outcome`. Default transport is unavailable, so + the run ends Failed / ``tepp_not_available`` -- never a fake theta. + """ + import hashlib + + digest = hashlib.sha256(b"lineageweave-synthetic-tepp-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-tepp-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) + on conflict do nothing + """, + (snapshot_id,), + ) + cur.execute( + """ + select analysis_run_id from analysis_run + where requested_by_account_id = %s + and idempotency_key = 'demo-tepp-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_tepp', 'demo-tepp-seed-2026-w02', + %s, '2026-01-12T12:00:00Z', 'tepp-run-v1', %s, %s, + '2026-01-12T12:34:00Z') + returning analysis_run_id + """, + (snapshot_id, requested_by_account_id, "d" * 64, "e" * 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), + ) + final_status, failure_code = tepp_seed_outcome() + events = [ + (1, "analysis_status_pending", "2026-01-12T12:35:00Z", None), + (2, "analysis_status_running", "2026-01-12T12:36:00Z", None), + (3, final_status, "2026-01-12T12:37:00Z", failure_code), + ] + for ordinal, status, occurred, fail in events: + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at, failure_code) + values (%s, %s, %s, %s, %s) + on conflict do nothing + """, + (run_id, ordinal, status, occurred, fail), + ) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--postgres-dsn", default=DEFAULT_POSTGRES_DSN) diff --git a/tests/test_seed_tepp_run.py b/tests/test_seed_tepp_run.py new file mode 100644 index 000000000..59a22699a --- /dev/null +++ b/tests/test_seed_tepp_run.py @@ -0,0 +1,9 @@ +"""Seeded TEPP analysis runs go through tepp_client, never a local model.""" + +from scripts.seed_demo_data import tepp_seed_outcome + + +def test_tepp_seed_outcome_is_unavailable_not_a_fake_score() -> None: + status, failure = tepp_seed_outcome() + assert status == "analysis_status_failed" + assert failure == "tepp_not_available" diff --git a/uv.lock b/uv.lock index 06408c2a9..411243f56 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.83.0" +version = "0.84.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 934e6e00231a60673d533742e80454d4a42542eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:30:20 +0000 Subject: [PATCH 2/6] fix: fail-closed TEPP seed on the shared Demo Corp snapshot #111 still marked a live unused envelope Succeeded, named a different capture than the registry row, and re-inserted frozen counts. Seed now reuses the lineage snapshot (ADR 0013), skips count inserts after the first run, and keeps missing or unused TEPP Failed. The home list tells the operator to open the run and connect TEPP; detail history keeps tepp_not_available. Co-authored-by: Seongho Bae --- AGENTS.md | 5 +- ARCHITECTURE.md | 13 +- CHANGELOG.d/0.84.0-tepp-analysis-run.md | 6 +- CHANGELOG.md | 12 +- CLAUDE.md | 14 ++ .../0013-normalized-analysis-run-registry.md | 7 +- docs/adr/0014-authorized-analysis-run-read.md | 19 +- frontend/src/App.test.tsx | 61 ++++++ frontend/src/App.tsx | 79 ++++++-- scripts/seed_demo_data.py | 184 ++++++++++-------- tests/test_seed_tepp_run.py | 80 +++++++- 11 files changed, 368 insertions(+), 112 deletions(-) create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 9b7d3195b..dba1c4b42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,10 @@ summary/chat, or invented commitment. A missing signal and a confidently-negative signal are different things. Keyman extraction, entity-relationship classification, post summary, in-popup chat, and commitment derivation go through contextual-orchestrator the same way -adjudication does -- never a raw LLM API. +adjudication does -- never a raw LLM API. Demo TEPP seed goes through +`tepp_client` the same way: a missing transport or an unused accepted +envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`), +never a fabricated theta or a local psychometric substitute. ## Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 362883dfa..b662b00b1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -468,17 +468,22 @@ run's scope whose `created_at` is at or before `knowledge_cutoff` without seeing later live rows or hidden bodies. Detail also returns revision and configuration digest prefixes. `make seed` also records a TEPP measurement run through -`tepp_client`; the default transport is unavailable, so that run is -Failed / `tepp_not_available` rather than a fabricated score. +`tepp_client` on that same snapshot; the default transport is +unavailable, so that run is Failed rather than a fabricated score. 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 and uses lookup labels plus occurrence times; a failure event keeps -its machine `failure_code` rather than an invented caption. The +its machine `failure_code` rather than an invented caption. Failed +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 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. +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`. ## 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 18267d698..080cc8240 100644 --- a/CHANGELOG.d/0.84.0-tepp-analysis-run.md +++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md @@ -1,4 +1,6 @@ # 0.84.0 TEPP analysis-run seed -Seed writes `analysis_run_tepp` via `tepp_client`. Missing transport -is Failed / `tepp_not_available`, not a fake measurement. +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. diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a57b24a..22f4878b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,14 @@ All notable changes to this project are documented here. Format follows ### Added - `make seed` records a Demo Corp TEPP measurement run through - `tepp_client`. The default transport is unavailable, so the home - list shows "TEPP measurement · Failed · Demo Corp" with - `tepp_not_available` -- never a fabricated theta. TEPP stays a - wire client, not a local psychometric engine. + `tepp_client` on the same snapshot as the lineage run (ADR 0013). + The default transport is unavailable, so the home list shows + "TEPP measurement · Failed · Demo Corp" and tells the operator to + open the run, then connect the measurement service. Detail history + 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. ## [0.83.0] - 2026-08-16 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..3af72ad45 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,14 @@ +# CLAUDE.md + +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) + +`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 +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. diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index 1d5a59866..631c07cd2 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -242,7 +242,12 @@ Acceptance requires: 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. + contracts are present on reviewed main branches. Seed now records a + Failed TEPP run through `tepp_client` on the shared Demo Corp snapshot; + a live transport remains a later slice. A missing or unused TEPP + envelope must stay Failed (`tepp_not_available` / + `tepp_result_not_persisted`) and must not write a local psychometric + substitute. 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. diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index c0f32beef..dea201bfc 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -38,14 +38,23 @@ LineageWeave owns a fail-closed read projection of the #89 registry: ## Consequences -`make seed` writes one synthetic Demo Corp lineage run so the existing -React home page can show Analysis runs without a second application. -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. +`make seed` writes one synthetic Demo Corp lineage run and one TEPP +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. ## References +American Educational Research Association, American Psychological +Association, & National Council on Measurement in Education. (2014). +*Standards for educational and psychological testing*. American +Educational Research Association. + 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/src/App.test.tsx b/frontend/src/App.test.tsx index 4e5fe7da1..e2a30c684 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -169,6 +169,51 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", has_commitment: true, ticket }), ); } + if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { + return Promise.resolve( + jsonResponse({ + 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, + }, + ], + 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", + }, + ], + }), + ); + } if (url.endsWith("/api/analysis-runs/run-demo-lineage")) { return Promise.resolve( jsonResponse({ @@ -1394,6 +1439,9 @@ describe("App, authenticated", () => { const list = screen.getByRole("list", { name: "Analysis runs" }); expect(list).toHaveTextContent("Lineage reconstruction · Succeeded · Demo Corp"); expect(list).toHaveTextContent("TEPP measurement · Failed · Demo Corp"); + expect(list).toHaveTextContent( + "Open this run to see why it failed, then connect the measurement service and re-run.", + ); expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); expect(list).not.toHaveTextContent("select "); @@ -1416,6 +1464,19 @@ describe("App, authenticated", () => { await userEvent.click(screen.getByRole("button", { name: "Open run post: Public post" })); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + + await userEvent.click( + screen.getByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + }), + ); + expect( + await screen.findByRole("heading", { name: "TEPP measurement · Failed · Demo Corp" }), + ).toBeInTheDocument(); + const teppHistory = screen.getByRole("list", { name: "Analysis run status history" }); + expect(teppHistory).toHaveTextContent("Failed 2026-01-12 12:37 · tepp_not_available"); + expect(screen.getByText(/cutoff corpus TEPP would measure/i)).toBeInTheDocument(); + expect(teppHistory).not.toHaveTextContent("Succeeded"); }); 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 f866bb302..50602c687 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1353,6 +1353,48 @@ function analysisRunCaption(run: AnalysisRun): string { .join(" · "); } +/** + * 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. + */ +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."; + } + return null; +} + +/** + * Empty-corpus copy that tells the operator what to do next. + */ +function analysisRunEmptyPostsHint(run: AnalysisRun): string { + if (run.run_kind_code === "analysis_run_tepp") { + return ( + "No posts were available at this cutoff for TEPP to measure. " + + "Open a later run, or ask an administrator to capture a newer snapshot." + ); + } + return ( + "No posts were available at this cutoff. Open a later run, or ask an " + + "administrator to capture a newer snapshot." + ); +} + +/** + * Corpus copy for a TEPP run that already has cutoff posts. + * + * Those titles are the measurement bag, not a reconstruction result. + */ +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." + ); +} + function AnalysisRunsPanel({ accessToken, onSelectPost, @@ -1387,6 +1429,8 @@ function AnalysisRunsPanel({ if (error && runs === null) return

{error}

; if (runs === null) return

Loading analysis runs...

; + const corpusHint = selected ? analysisRunCorpusHint(selected) : null; + return (
@@ -1404,6 +1448,7 @@ function AnalysisRunsPanel({ (count) => count.count_type_code === "analysis_count_document", ); const caption = analysisRunCaption(run); + const nextAction = analysisRunNextAction(run); return (
  • ); @@ -1448,20 +1494,25 @@ function AnalysisRunsPanel({ ))} )} - {selected.visible_posts && selected.visible_posts.length > 0 && ( -
      - {selected.visible_posts.map((post) => ( -
    • - -
    • - ))} -
    + {selected.visible_posts && selected.visible_posts.length > 0 ? ( + <> + {corpusHint &&

    {corpusHint}

    } +
      + {selected.visible_posts.map((post) => ( +
    • + +
    • + ))} +
    + + ) : ( +

    {analysisRunEmptyPostsHint(selected)}

    )}
    )} diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 75a7330f3..f6f575ccc 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -19,6 +19,7 @@ from __future__ import annotations import argparse +import hashlib import os import sys from pathlib import Path @@ -30,6 +31,7 @@ import psycopg2 from lineageweave.http_client import get_json_list, post_form +from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable REALM = "lineageweave-demo" DEFAULT_POSTGRES_DSN = "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave" @@ -37,6 +39,12 @@ DEFAULT_KEYCLOAK_ADMIN_USER = os.environ.get("KEYCLOAK_ADMIN", "admin") DEFAULT_VALKEY_URL = "redis://localhost:16379/0" +# ADR 0013: one Demo Corp capture, many runs (lineage + TEPP). +DEMO_SOURCE_SNAPSHOT_MATERIAL = b"lineageweave-synthetic-demo-snapshot-v1" +DEMO_SOURCE_CONTRACT_VERSION = "demo-source-contract-v1" +DEMO_LINEAGE_IDEMPOTENCY_KEY = "demo-lineage-seed-2026-w02" +DEMO_TEPP_IDEMPOTENCY_KEY = "demo-tepp-seed-2026-w02" + # (post_title, ticket_title, due_date) -- Event Lineage fixtures a report # member click opens. Activity seed uses the same titles so Valkey matches. FIXTURE_TICKET_SPECS = ( @@ -1208,36 +1216,57 @@ 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. +def demo_source_snapshot_sha256() -> str: + """Return the reusable Demo Corp snapshot digest (never a source row).""" + return hashlib.sha256(DEMO_SOURCE_SNAPSHOT_MATERIAL).hexdigest() - 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() +def _ensure_demo_source_snapshot(cur): + """Return the shared Demo Corp capture, inserting it on first seed. + + Lineage and TEPP runs share this snapshot (ADR 0013: one capture, + many runs). The digest is a hash of a fixed demo contract string -- + never a source row or DSN. + """ + digest = demo_source_snapshot_sha256() 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] + if snapshot_row is not None: + return snapshot_row[0] + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, %s, + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + (digest, DEMO_SOURCE_CONTRACT_VERSION), + ) + return cur.fetchone()[0] + + +def _ensure_demo_source_counts(cur, snapshot_id) -> None: + """Insert demo counts only when the snapshot still has none. + + ``enforce_analysis_source_count_freeze`` runs BEFORE INSERT. After + the first run points at the snapshot, a later ``INSERT ... ON + CONFLICT DO NOTHING`` still raises ``analysis_source_count_frozen_after_run`` + and rolls back the whole ``seed()`` transaction. Skip when counts + already exist so ``make seed`` can be re-run. + """ + cur.execute( + "select 1 from analysis_source_count " + "where analysis_source_snapshot_id = %s limit 1", + (snapshot_id,), + ) + if cur.fetchone() is not None: + return cur.execute( """ insert into analysis_source_count @@ -1247,17 +1276,27 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - (%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), ) + + +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. Reuses the + shared Demo Corp snapshot so a later TEPP run can attach to the + same capture. + """ + snapshot_id = _ensure_demo_source_snapshot(cur) + _ensure_demo_source_counts(cur, 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' + and idempotency_key = %s """, - (requested_by_account_id,), + (requested_by_account_id, DEMO_LINEAGE_IDEMPOTENCY_KEY), ) run_row = cur.fetchone() if run_row is None: @@ -1268,12 +1307,18 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - 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', + 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, requested_by_account_id, "b" * 64, "c" * 40), + ( + snapshot_id, + DEMO_LINEAGE_IDEMPOTENCY_KEY, + requested_by_account_id, + "b" * 64, + "c" * 40, + ), ) run_id = cur.fetchone()[0] else: @@ -1303,75 +1348,50 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - ) -def tepp_seed_outcome() -> tuple[str, str | None]: - """Ask TEPP through the published client. A missing transport is Failed. - - Never invents a psychometric score. ``tepp_not_available`` means the - channel was dropped, not a calibrated negative result. - """ - from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable - - request = AnalysisRunRequest( - idempotency_key="demo-tepp-seed-2026-w02", +def tepp_seed_request() -> AnalysisRunRequest: + """Build the Demo Corp TEPP request against the shared snapshot digest.""" + return AnalysisRunRequest( + idempotency_key=DEMO_TEPP_IDEMPOTENCY_KEY, tenant_workspace_id="demo-workspace", - snapshot_id="demo-source-contract-v1", + snapshot_id=demo_source_snapshot_sha256(), knowledge_cutoff="2026-01-12T12:00:00Z", model_contract_version="tepp-analysis-run-v1", output_profile="calibrated_event_measurement", ) + + +def tepp_seed_outcome(client: TeppClient | None = None) -> tuple[str, str | None]: + """Ask TEPP through the published client. A missing transport is Failed. + + Never invents a psychometric score. ``tepp_not_available`` means the + channel was dropped, not a calibrated negative result. A live + envelope is also not a persistable measurement in this seed, so the + run is not stamped Succeeded. + """ + request = tepp_seed_request() try: - TeppClient().submit_analysis_run(request) + (client or TeppClient()).submit_analysis_run(request) except TeppNotAvailable: return "analysis_status_failed", "tepp_not_available" - return "analysis_status_succeeded", None + return "analysis_status_failed", "tepp_result_not_persisted" def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> None: """Insert one Demo-Corp TEPP run so the kind is visible without a live TEPP. - Uses :func:`tepp_seed_outcome`. Default transport is unavailable, so - the run ends Failed / ``tepp_not_available`` -- never a fake theta. + Uses :func:`tepp_seed_outcome` against the shared lineage snapshot. + Default transport is unavailable, so the run ends Failed / + ``tepp_not_available`` -- never a fake theta. """ - import hashlib - - digest = hashlib.sha256(b"lineageweave-synthetic-tepp-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-tepp-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) - on conflict do nothing - """, - (snapshot_id,), - ) + snapshot_id = _ensure_demo_source_snapshot(cur) + _ensure_demo_source_counts(cur, snapshot_id) cur.execute( """ select analysis_run_id from analysis_run where requested_by_account_id = %s - and idempotency_key = 'demo-tepp-seed-2026-w02' + and idempotency_key = %s """, - (requested_by_account_id,), + (requested_by_account_id, DEMO_TEPP_IDEMPOTENCY_KEY), ) run_row = cur.fetchone() if run_row is None: @@ -1382,12 +1402,18 @@ def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> No requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_tepp', 'demo-tepp-seed-2026-w02', + values (%s, 'analysis_run_tepp', %s, %s, '2026-01-12T12:00:00Z', 'tepp-run-v1', %s, %s, '2026-01-12T12:34:00Z') returning analysis_run_id """, - (snapshot_id, requested_by_account_id, "d" * 64, "e" * 40), + ( + snapshot_id, + DEMO_TEPP_IDEMPOTENCY_KEY, + requested_by_account_id, + "d" * 64, + "e" * 40, + ), ) run_id = cur.fetchone()[0] else: diff --git a/tests/test_seed_tepp_run.py b/tests/test_seed_tepp_run.py index 59a22699a..c865c475c 100644 --- a/tests/test_seed_tepp_run.py +++ b/tests/test_seed_tepp_run.py @@ -1,9 +1,85 @@ """Seeded TEPP analysis runs go through tepp_client, never a local model.""" -from scripts.seed_demo_data import tepp_seed_outcome +from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable +from scripts.seed_demo_data import ( + _ensure_demo_source_counts, + demo_source_snapshot_sha256, + tepp_seed_outcome, + tepp_seed_request, +) -def test_tepp_seed_outcome_is_unavailable_not_a_fake_score() -> None: +class _RecordingUnavailableClient(TeppClient): + """Default-path stand-in that records the request then drops the channel.""" + + def __init__(self) -> None: + super().__init__() + self.submitted: list[AnalysisRunRequest] = [] + + def submit_analysis_run(self, request: AnalysisRunRequest) -> dict[str, object]: + self.submitted.append(request) + raise TeppNotAvailable("TEPP has no live HTTP endpoint yet.") + + +class _AcceptingClient(TeppClient): + """Transport that returns an envelope without a persistable measurement.""" + + def __init__(self) -> None: + super().__init__(transport=lambda _payload: {"status": "accepted"}) + + +class _CountCursor: + """Minimal cursor for proving re-seed skips a frozen count insert.""" + + def __init__(self, existing_counts: bool) -> None: + self.existing_counts = existing_counts + self.statements: list[str] = [] + + def execute(self, sql: str, _params=None) -> None: + self.statements.append(" ".join(sql.split())) + + def fetchone(self): + if self.existing_counts and "from analysis_source_count" in self.statements[-1]: + return (1,) + return None + + +def test_tepp_seed_request_targets_the_shared_demo_snapshot() -> None: + request = tepp_seed_request() + assert request.snapshot_id == demo_source_snapshot_sha256() + assert request.idempotency_key == "demo-tepp-seed-2026-w02" + assert request.model_contract_version == "tepp-analysis-run-v1" + assert request.output_profile == "calibrated_event_measurement" + + +def test_tepp_seed_outcome_calls_client_and_does_not_invent_a_score() -> None: + client = _RecordingUnavailableClient() + status, failure = tepp_seed_outcome(client) + assert status == "analysis_status_failed" + assert failure == "tepp_not_available" + assert client.submitted == [tepp_seed_request()] + + +def test_tepp_seed_outcome_default_client_is_unavailable_not_a_fake_score() -> None: status, failure = tepp_seed_outcome() assert status == "analysis_status_failed" assert failure == "tepp_not_available" + + +def test_tepp_seed_outcome_does_not_treat_an_empty_envelope_as_success() -> None: + status, failure = tepp_seed_outcome(_AcceptingClient()) + assert status == "analysis_status_failed" + assert failure == "tepp_result_not_persisted" + + +def test_ensure_demo_source_counts_skips_insert_when_counts_exist() -> None: + cursor = _CountCursor(existing_counts=True) + _ensure_demo_source_counts(cursor, "snapshot-1") + assert any("from analysis_source_count" in sql for sql in cursor.statements) + assert not any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements) + + +def test_ensure_demo_source_counts_inserts_when_the_snapshot_is_empty() -> None: + cursor = _CountCursor(existing_counts=False) + _ensure_demo_source_counts(cursor, "snapshot-1") + assert any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements) From 24d38c091e28668371d717d83b195bcb7a6dbf10 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:35:46 +0000 Subject: [PATCH 3/6] fix: keep failed-run next actions kind-specific A failed lineage row must not tell the operator to connect TEPP. Stacked PRs now run the same GitHub Checks as PRs to main. Co-authored-by: Seongho Bae --- .github/workflows/tests.yml | 1 - ARCHITECTURE.md | 5 ++-- CHANGELOG.d/0.84.0-tepp-analysis-run.md | 6 ++--- CHANGELOG.md | 4 ++- docs/adr/0014-authorized-analysis-run-read.md | 9 ++++--- frontend/src/App.test.tsx | 25 +++++++++++++++++-- frontend/src/App.tsx | 18 +++++++++---- 7 files changed, 50 insertions(+), 18 deletions(-) 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..2492ee50d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -475,9 +475,10 @@ 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 · 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.md b/CHANGELOG.md index 22f4878b4..c36b2666d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,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/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index dea201bfc..841dfb383 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -43,10 +43,11 @@ 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. Write/rebuild APIs, a live TEPP +transport, and a fuller Analysis Run Console remain later slices. ## References diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e2a30c684..5fc04aac9 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -59,6 +59,7 @@ describe("App, authenticated", () => { chatUnavailable?: boolean; searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; + failedLineageRun?: boolean; }) { const statusLabel: Record = { open: "Open", @@ -269,8 +270,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: [ @@ -1479,6 +1482,24 @@ 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("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..e5b5994f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1356,14 +1356,22 @@ 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."; + default: + return "Open this run to see why it failed, then retry after the blocking service is connected."; } - return null; } /** From a2ff249d0221eb89d0e15008f5a5c469f43d4ca3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:35:51 +0000 Subject: [PATCH 4/6] docs: keep TEPP next-action copy off failed lineage rows Co-authored-by: Seongho Bae --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3af72ad45..0a2950e91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,4 +11,6 @@ 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. From 5aa3c8e7f80b28a5f148adf1fef2d2cbde554a85 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:37:18 +0000 Subject: [PATCH 5/6] fix: keep TEPP corpus hint off a succeeded measurement A calibrated TEPP row must not tell the operator to replace Failed. Co-authored-by: Seongho Bae --- frontend/src/App.test.tsx | 38 +++++++++++++++++++++++++++++++------- frontend/src/App.tsx | 11 +++++++---- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 5fc04aac9..c95abf160 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -60,6 +60,7 @@ describe("App, authenticated", () => { searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; failedLineageRun?: boolean; + succeededTeppRun?: boolean; }) { const statusLabel: Record = { open: "Open", @@ -179,8 +180,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_failed", - status_label: "Failed", + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -206,10 +209,14 @@ describe("App, authenticated", () => { }, { status_ordinal: 3, - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", occurred_at: "2026-01-12T12:37:00Z", - failure_code: "tepp_not_available", + ...(options?.succeededTeppRun + ? {} + : { failure_code: "tepp_not_available" }), }, ], }), @@ -291,8 +298,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_failed", - status_label: "Failed", + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -1500,6 +1509,21 @@ describe("App, authenticated", () => { 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("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 e5b5994f9..a5f66d7a9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1397,10 +1397,13 @@ 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." - ); + if (run.status_code === "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." + ); + } + return "These posts are the cutoff corpus this TEPP run measured."; } function AnalysisRunsPanel({ From 47ee37e71d42a47b245ccab59d5b3618c63aec6f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:55:18 +0000 Subject: [PATCH 6/6] feat: let operators request a pending lineage reconstruction (v0.85.0) POST /api/analysis-runs records a pending lineage run on a snapshot already bound to the caller's corporate entity. The same account/key replays; a drifted cutoff is 409. TEPP and period-report kinds are rejected so this path cannot invent a measurement. The home page button is Request lineage reconstruction. A pending TEPP corpus hint no longer says the run already measured. A failed period-report row points at the Reports panel. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 3 + CHANGELOG.d/0.85.0-analysis-run-write.md | 8 + CHANGELOG.md | 18 + CLAUDE.md | 5 +- backend/app/analysis_run_write.py | 372 ++++++++++++++++++ backend/app/main.py | 66 ++++ backend/tests/test_api.py | 77 ++++ .../0013-normalized-analysis-run-registry.md | 3 + docs/adr/0014-authorized-analysis-run-read.md | 6 +- docs/adr/0017-analysis-run-write.md | 98 +++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 3 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 282 +++++++++---- frontend/src/App.tsx | 60 ++- frontend/src/api.ts | 17 + lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_analysis_run_write.py | 299 ++++++++++++++ 18 files changed, 1232 insertions(+), 91 deletions(-) create mode 100644 CHANGELOG.d/0.85.0-analysis-run-write.md create mode 100644 backend/app/analysis_run_write.py create mode 100644 docs/adr/0017-analysis-run-write.md create mode 100644 tests/test_analysis_run_write.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2492ee50d..dcf5c8439 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -485,6 +485,9 @@ 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.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 c36b2666d..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 diff --git a/CLAUDE.md b/CLAUDE.md index 0a2950e91..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 @@ -13,4 +13,5 @@ theta or a local psychometric substitute. The home list caption stays `kind · status · entity`; the machine failure code is detail-only (ADR 0014). Open a Failed TEPP row, then connect a live TEPP transport. A failed lineage row retries reconstruction -- it does not -mention TEPP. +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 841dfb383..4a4e1102f 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -46,8 +46,10 @@ keeps that machine code off the caption (this decision) and instead 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. Write/rebuild APIs, a live TEPP -transport, and a fuller Analysis Run Console remain later slices. +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 c95abf160..d0a62f4a5 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -61,6 +61,8 @@ describe("App, authenticated", () => { verificationEvidenceUrl?: string | null; failedLineageRun?: boolean; succeededTeppRun?: boolean; + pendingTeppRun?: boolean; + failedReportRun?: boolean; }) { const statusLabel: Record = { open: "Open", @@ -82,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); @@ -180,10 +183,16 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: options?.succeededTeppRun - ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "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: [ @@ -194,31 +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: 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" }), - }, - ], + 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" }), + }, + ], }), ); } @@ -231,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: [ @@ -258,61 +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: 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?.succeededTeppRun - ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: 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, - }, - ], - }, - ], + analysis_runs: requestedLineageRun + ? [requestedLineageRun, ...analysisRuns] + : analysisRuns, }), ); } @@ -1524,6 +1596,62 @@ describe("App, authenticated", () => { 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 a5f66d7a9..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, @@ -1369,6 +1370,8 @@ function analysisRunNextAction(run: AnalysisRun): string | null { 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."; } @@ -1397,13 +1400,25 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string { */ function analysisRunCorpusHint(run: AnalysisRun): string | null { if (run.run_kind_code !== "analysis_run_tepp") return null; - if (run.status_code === "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." - ); + 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; } - return "These posts are the cutoff corpus this TEPP run measured."; } function AnalysisRunsPanel({ @@ -1416,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) @@ -1423,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 { @@ -1446,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())