From a677dd05f1c4e590e33a0034c64553e69c66677b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:07:15 +0900 Subject: [PATCH 1/2] feat: show labeled outbox delivery on analysis-run detail (v0.95.0) Open the seeded Demo Corp lineage run to see Claimed then Delivered. Stream entry ids stay off the payload. No TEPP theta is invented. --- ARCHITECTURE.md | 6 +-- .../0.95.0-analysis-run-outbox-delivery.md | 4 ++ CHANGELOG.md | 9 +++++ backend/app/analysis_run_ingestion.py | 40 +++++++++++++++++++ docs/adr/0023-analysis-run-outbox.md | 7 ++-- frontend/package.json | 2 +- frontend/src/App.test.tsx | 21 ++++++++++ frontend/src/App.tsx | 9 +++++ frontend/src/api.ts | 8 ++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 12 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 CHANGELOG.d/0.95.0-analysis-run-outbox-delivery.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1fcd327a8..abfbb4fcc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -507,9 +507,9 @@ 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, -the designed A-100 fork as clickable reconstructed edges, and -"TEPP measurement · Failed · Demo Corp" whose detail history ends -in Failed / `tepp_not_available`. +the designed A-100 fork as clickable reconstructed edges, Claimed +then Delivered outbox times, and "TEPP measurement · Failed · Demo +Corp" whose detail history ends in Failed / `tepp_not_available`. A run-bearing registry is emptied only after an unrevoked `analysis_run_retention_grant` and `GRANT analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')` diff --git a/CHANGELOG.d/0.95.0-analysis-run-outbox-delivery.md b/CHANGELOG.d/0.95.0-analysis-run-outbox-delivery.md new file mode 100644 index 000000000..a92a4149b --- /dev/null +++ b/CHANGELOG.d/0.95.0-analysis-run-outbox-delivery.md @@ -0,0 +1,4 @@ +# 0.95.0 Analysis-run outbox delivery history + +Open a seeded Succeeded lineage run to see Claimed then Delivered. +Stream ids stay off the payload. diff --git a/CHANGELOG.md b/CHANGELOG.md index a687e410c..9f4c930cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.95.0] - 2026-08-17 + +### Added + +- Analysis-run detail now lists labeled outbox delivery: Claimed then + Delivered (ADR 0023). After `make seed`, open the Demo Corp lineage + run to see those times. Stream entry ids stay off the payload. No + TEPP theta is invented. + ## [0.94.0] - 2026-08-17 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 2405b7b89..fe27c9e75 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -164,6 +164,45 @@ async def _status_history( return history +async def fetch_outbox_deliveries( + conn: asyncpg.Connection, + analysis_run_id: str, +) -> list[dict[str, Any]]: + """Labeled claim/delivery events for one already-visible run. + + Missing outbox tables mean migration 0023 is not applied. Stream + entry ids stay off the payload -- they are not buyer evidence. + """ + try: + rows = await conn.fetch( + """ + select delivery_ordinal, delivery_status_code, occurred_at + from analysis_run_outbox_delivery + where analysis_run_id = $1::uuid + order by delivery_ordinal + """, + analysis_run_id, + ) + except asyncpg.UndefinedTableError: + return [] + labels = await labels_for_codes( + conn, + [row["delivery_status_code"] for row in rows], + ) + return [ + { + "delivery_ordinal": int(row["delivery_ordinal"]), + "delivery_status_code": row["delivery_status_code"], + "delivery_status_label": labels.get( + row["delivery_status_code"], + row["delivery_status_code"], + ), + "occurred_at": _iso(row["occurred_at"]), + } + for row in rows + ] + + async def _serialize_runs( conn: asyncpg.Connection, rows: list[asyncpg.Record], @@ -256,6 +295,7 @@ async def fetch_visible_analysis_run( if row["failure_code"]: detail["failure_code"] = row["failure_code"] detail["status_history"] = await _status_history(conn, analysis_run_id) + detail["outbox_deliveries"] = await fetch_outbox_deliveries(conn, analysis_run_id) detail["visible_posts"] = await fetch_visible_scope_posts( conn, row["scope_kind_code"], diff --git a/docs/adr/0023-analysis-run-outbox.md b/docs/adr/0023-analysis-run-outbox.md index 460b83a0a..e89630ef8 100644 --- a/docs/adr/0023-analysis-run-outbox.md +++ b/docs/adr/0023-analysis-run-outbox.md @@ -69,9 +69,10 @@ before the registry rows. ## Consequences Start survives a crash after Running. Refreshing a queued run finishes -the same work item. Valkey is a wake-up, not a source of truth. Do not -invent a theta, and do not stamp Succeeded from a missing reconstruct -library. +the same work item. Valkey is a wake-up, not a source of truth. Detail +lists labeled Claimed / Delivered events; stream entry ids stay off +the payload. Do not invent a theta, and do not stamp Succeeded from a +missing reconstruct library. ## References — APA 7th diff --git a/frontend/package.json b/frontend/package.json index 3b2e21acb..b92e7c880 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.94.0", + "version": "0.95.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index de5a6367b..11762ffe2 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -371,6 +371,20 @@ describe("App, authenticated", () => { }, ], reconstruction_result_sha256: "aa".repeat(32), + outbox_deliveries: [ + { + delivery_ordinal: 1, + delivery_status_code: "analysis_outbox_claimed", + delivery_status_label: "Claimed", + occurred_at: "2026-01-12T12:32:00Z", + }, + { + delivery_ordinal: 2, + delivery_status_code: "analysis_outbox_delivered", + delivery_status_label: "Delivered", + occurred_at: "2026-01-12T12:33:00Z", + }, + ], code_revision_sha: "abcdef0123456789deadbeefcafebabe", configuration_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", @@ -1869,6 +1883,8 @@ describe("App, authenticated", () => { expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); expect(list).not.toHaveTextContent("select "); + expect(list).not.toHaveTextContent("Claimed"); + expect(list).not.toHaveTextContent("Delivered"); expect(list).not.toHaveTextContent("Code abcdef012345"); expect(list).not.toHaveTextContent("Config 0123456789ab"); expect(list).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe"); @@ -1900,6 +1916,11 @@ describe("App, authenticated", () => { expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); expect(history).toHaveTextContent("Running 2026-01-12 12:32"); expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); + const outbox = screen.getByRole("list", { name: "Analysis run outbox delivery" }); + expect(outbox).toHaveTextContent("Claimed 2026-01-12 12:32"); + expect(outbox).toHaveTextContent("Delivered 2026-01-12 12:33"); + expect(outbox).not.toHaveTextContent("valkey"); + expect(outbox).not.toHaveTextContent("stream"); expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument(); const seededFork = screen.getByRole("list", { name: "Reconstructed lineage edges" }); expect(seededFork).toHaveTextContent( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67767b419..6bf05c4b0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1922,6 +1922,15 @@ function AnalysisRunsPanel({ ))} )} + {selected.outbox_deliveries && selected.outbox_deliveries.length > 0 && ( +
    + {selected.outbox_deliveries.map((event) => ( +
  1. + {event.delivery_status_label} {event.occurred_at.slice(0, 16).replace("T", " ")} +
  2. + ))} +
+ )} {selected.visible_posts && selected.visible_posts.length > 0 ? ( <> {corpusHint &&

{corpusHint}

} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b8269e2b0..f9fc5e4cc 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -536,6 +536,13 @@ export interface AnalysisRunStatusEvent { failure_code?: string; } +export interface AnalysisRunOutboxDelivery { + delivery_ordinal: number; + delivery_status_code: string; + delivery_status_label: string; + occurred_at: string; +} + export interface AnalysisRunReconstructedEdge { parent_post_id: string; parent_post_title: string; @@ -565,6 +572,7 @@ export interface AnalysisRun { requested_at: string; source_counts: AnalysisRunCount[]; status_history?: AnalysisRunStatusEvent[]; + outbox_deliveries?: AnalysisRunOutboxDelivery[]; visible_posts?: AnalysisRunVisiblePost[]; reconstructed_edges?: AnalysisRunReconstructedEdge[]; reconstruction_result_sha256?: string; diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index eebd45bcb..45eb83e01 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.94.0" +__version__ = "0.95.0" diff --git a/pyproject.toml b/pyproject.toml index d3682e502..cece2e1a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.94.0" +version = "0.95.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index 499690dbf..e01dbe0b9 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.94.0" +version = "0.95.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 008da981d6b4a1b48fd2ce869f2167d42f6b9456 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:53:43 +0900 Subject: [PATCH 2/2] feat: seed the Demo Corp period report on the analysis-run registry (v0.96.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After make seed, open Period report · Succeeded · Demo Corp to confirm the cutoff posts. Mean θ stays on the period-report panel. Start stays 422. No TEPP theta is invented. --- ARCHITECTURE.md | 4 + CHANGELOG.d/0.96.0-seed-period-report-run.md | 4 + CHANGELOG.md | 10 ++ CLAUDE.md | 8 +- .../0013-normalized-analysis-run-registry.md | 5 +- docs/adr/0014-authorized-analysis-run-read.md | 7 +- .../0024-seed-period-report-analysis-run.md | 67 +++++++++++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 4 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 97 +++++++++++++++---- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- scripts/seed_demo_data.py | 84 +++++++++++++++- tests/test_seed_report_run.py | 87 +++++++++++++++++ uv.lock | 2 +- 15 files changed, 347 insertions(+), 38 deletions(-) create mode 100644 CHANGELOG.d/0.96.0-seed-period-report-run.md create mode 100644 docs/adr/0024-seed-period-report-analysis-run.md create mode 100644 tests/test_seed_report_run.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index abfbb4fcc..a7f6dfebd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -510,6 +510,10 @@ Demo Corp" with "3 documents" and Pending / Running / Succeeded times, the designed A-100 fork as clickable reconstructed edges, Claimed then Delivered outbox times, and "TEPP measurement · Failed · Demo Corp" whose detail history ends in Failed / `tepp_not_available`. +Seed also records "Period report · Succeeded · Demo Corp" on that +same snapshot after the calibrated report tables are written +(ADR 0024). Open that row to confirm the cutoff posts; mean θ stays +on the period-report panel. Start stays 422. A run-bearing registry is emptied only after an unrevoked `analysis_run_retention_grant` and `GRANT analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')` diff --git a/CHANGELOG.d/0.96.0-seed-period-report-run.md b/CHANGELOG.d/0.96.0-seed-period-report-run.md new file mode 100644 index 000000000..037723ba6 --- /dev/null +++ b/CHANGELOG.d/0.96.0-seed-period-report-run.md @@ -0,0 +1,4 @@ +# 0.96.0 Seeded period-report analysis run + +After `make seed`, open **Period report · Succeeded · Demo Corp**. +Mean θ stays on the period-report panel. No theta is copied. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f4c930cc..4134cc94d 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.96.0] - 2026-08-17 + +### Added + +- `make seed` now records **Period report · Succeeded · Demo Corp** on + the shared snapshot after the calibrated report tables are written + (ADR 0024). Open that row to confirm the cutoff posts. Mean θ stays + on the period-report panel. Start stays 422. No TEPP theta is + invented. + ## [0.95.0] - 2026-08-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 3f347a2fe..8f7bfda76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,10 +15,10 @@ back 0020 then 0018. The published phrase is not a secret. Do not retention grant to the application `DATABASE_URL` login. ADR 0019 is the R&R catalog-id bind, not this purge. -## Analysis-run seed (v0.85.0) +## Analysis-run seed (v0.96.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 +`make seed` writes a Demo Corp lineage run, a TEPP run, and a Succeeded +period-report run on the same snapshot (ADR 0013 / ADR 0024). 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 @@ -40,3 +40,5 @@ frozen cutoff bag (ADR 0021 / ADR 0023) or submits TEPP through envelope is Failed. Failed TEPP is terminal — request a new run, then start. Do not invent a theta. Hover the Result prefix to read the parent-choice digest. +After `make seed`, open **Period report · Succeeded · Demo Corp** +to confirm the cutoff posts; mean θ stays on the period-report panel. diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index 96e1d8917..15fd040d6 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -255,7 +255,10 @@ Acceptance requires: `tepp_client` on the frozen snapshot; a persistable measurement 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. + not write a local psychometric substitute. Seed also records a + Succeeded `analysis_run_report` on that snapshot after the + period-report tables are written (ADR 0024); the registry row does + not copy a theta. 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 500c2bc2a..a8d82acbf 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -38,9 +38,10 @@ LineageWeave owns a fail-closed read projection of the #89 registry: ## Consequences -`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 / +`make seed` writes one synthetic Demo Corp lineage run, one TEPP +run, and one Succeeded period-report run on the same snapshot so the +existing React home page can show all three kinds without a second +application (ADR 0024). 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 TEPP run, then connect the measurement diff --git a/docs/adr/0024-seed-period-report-analysis-run.md b/docs/adr/0024-seed-period-report-analysis-run.md new file mode 100644 index 000000000..9ee8f90b7 --- /dev/null +++ b/docs/adr/0024-seed-period-report-analysis-run.md @@ -0,0 +1,67 @@ +# ADR 0024 — Seed records the built period report on the shared snapshot + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-17 +**Depends on:** ADR 0013 normalized analysis-run registry; ADR 0003 +fast-mlsirm report integration; ADR 0014 authorized analysis-run read +**Refs:** Issue #79 (Milestone 2 parent). After `make seed`, lineage and +TEPP registry rows were visible on home Analysis runs, but the +calibrated period report lived only on the separate report panel. +ADR 0022 is authorized TEPP start. ADR 0023 is the durable start +outbox. This decision is the next free slot. + +## Context + +Seed already scores Demo Corp week-2/week-3 reports through +`fast-mlsirm` and persists them on the report tables. The analysis-run +registry already has `analysis_run_report`. Operators who opened +Analysis runs after `make seed` could retry a Failed TEPP transport or +inspect a Succeeded lineage tree, then had no registry row for the +report they could already see on the period-report panel. + +A fake Failed report row would contradict the built report. Copying +mean θ onto `analysis_run` would invent a psychometric field the +registry is not allowed to store (ADR 0013). Starting a period-report +run through the lineage/TEPP outbox would invent a calibrated score +on a path that is not allowed to (ADR 0021 / ADR 0022 / ADR 0023). + +## Decision + +- `_seed_demo_period_report` still builds the calibrated report first. +- `_seed_demo_report_run` then inserts `analysis_run_report` on the + same Demo Corp snapshot, scoped to the same corporate entity. +- The lifecycle is Pending → Running → Succeeded because the report + tables already hold the scored period. The run row stores only + registry digests and counts — never a theta, item bank, or provider + body. +- Home next-action copy for a Succeeded report stays empty. Failed + report fixtures still say rebuild the period report. +- `POST /api/analysis-runs` stays lineage-or-TEPP (ADR 0017). + `POST /api/analysis-runs/{id}/start` stays 422 for this kind. + This slice does not add a Request period-report button, does not + enqueue outbox work, and does not call TEPP. + +## Consequences + +After `make seed`, Demo Analyst opens Analysis runs and sees +**Period report · Succeeded · Demo Corp** next to the lineage and TEPP +rows. Opening it shows the cutoff posts. Mean θ remains on the +period-report panel. Re-seed is idempotent on +`demo-report-seed-2026-w02`. + +## References — APA 7th + +American Educational Research Association, American Psychological +Association, & National Council on Measurement in Education. (2014). +*Standards for educational and psychological testing*. American +Educational Research Association. + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. +World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ + +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 aab8bb551..11e4871fd 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -2,14 +2,14 @@ **Status:** Active PR evidence; not protected-main truth until merge. **Scope:** Migrations 0018–0023, ADR 0013 / 0017 / 0020 / 0021 / 0022 / -0023, rollback, and real-PostgreSQL contract tests. +0023 / 0024, rollback, and real-PostgreSQL contract tests. ## Standards mapped to implementation | Source | Product implication | Implemented evidence | |---|---|---| | W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. | -| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Detail compares live `updated_at` with that cutoff and marks titles rewritten after the run. | +| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Detail compares live `updated_at` with that cutoff and marks titles rewritten after the run. Seed records the built period report as a later Succeeded run on that same snapshot (ADR 0024) without copying a theta onto the registry row. | | W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. | | 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. | diff --git a/frontend/package.json b/frontend/package.json index b92e7c880..85873e815 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.95.0", + "version": "0.96.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 11762ffe2..49c9e7869 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -62,6 +62,7 @@ describe("App, authenticated", () => { failedLineageRun?: boolean; runningLineageRun?: boolean; failedReportRun?: boolean; + succeededReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; postBody?: string; @@ -178,6 +179,7 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/analysis-runs/run-demo-report")) { + const reportSucceeded = Boolean(options?.succeededReportRun); return Promise.resolve( jsonResponse({ analysis_run_id: "run-demo-report", @@ -186,8 +188,8 @@ 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: reportSucceeded ? "analysis_status_succeeded" : "analysis_status_failed", + status_label: reportSucceeded ? "Succeeded" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:38:00Z", source_counts: [ @@ -197,22 +199,45 @@ describe("App, authenticated", () => { count_value: 3, }, ], - visible_posts: [], - status_history: [ - { - status_ordinal: 1, - status_code: "analysis_status_pending", - status_label: "Pending", - occurred_at: "2026-01-12T12:39:00Z", - }, - { - status_ordinal: 2, - status_code: "analysis_status_failed", - status_label: "Failed", - occurred_at: "2026-01-12T12:40:00Z", - failure_code: "period_report_rebuild_failed", - }, - ], + visible_posts: reportSucceeded + ? [{ post_id: "post-1", post_title: "Public post" }] + : [], + status_history: reportSucceeded + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:39:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:40:00Z", + }, + { + status_ordinal: 3, + status_code: "analysis_status_succeeded", + status_label: "Succeeded", + occurred_at: "2026-01-12T12:41:00Z", + }, + ] + : [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:39:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_failed", + status_label: "Failed", + occurred_at: "2026-01-12T12:40:00Z", + failure_code: "period_report_rebuild_failed", + }, + ], }), ); } @@ -640,7 +665,7 @@ describe("App, authenticated", () => { }, ], }, - ...(options?.failedReportRun + ...(options?.failedReportRun || options?.succeededReportRun ? [ { analysis_run_id: "run-demo-report", @@ -649,8 +674,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" as const, - status_label: "Failed", + status_code: options?.succeededReportRun + ? ("analysis_status_succeeded" as const) + : ("analysis_status_failed" as const), + status_label: options?.succeededReportRun ? "Succeeded" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:38:00Z", source_counts: [ @@ -2061,6 +2088,34 @@ describe("App, authenticated", () => { expect(teppButton).not.toHaveTextContent("reconstruction"); }); + it("does not tell a succeeded period report to rebuild, reconstruct, or measure", async () => { + stubBackend({ succeededReportRun: true }); + render(); + + const reportButton = await screen.findByRole("button", { + name: "Open analysis run: Period report · Succeeded · Demo Corp", + }); + expect(reportButton).not.toHaveTextContent("rebuild the period report"); + expect(reportButton).not.toHaveTextContent("Reconstruction has not started yet"); + expect(reportButton).not.toHaveTextContent("The report has not been built yet"); + expect(reportButton).not.toHaveTextContent("measurement service"); + expect(reportButton).not.toHaveTextContent("θ"); + + await userEvent.click(reportButton); + expect( + await screen.findByRole("heading", { name: "Period report · Succeeded · Demo Corp" }), + ).toBeInTheDocument(); + expect(screen.queryByText(/rebuild the period report/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); + expect(screen.queryByText(/The report has not been built yet/)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Open live post: Public post", + }), + ).toBeInTheDocument(); + }); + it("does not tell a failed period report to connect the measurement service", async () => { stubBackend({ failedReportRun: true }); render(); diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 45eb83e01..6d722c46b 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.95.0" +__version__ = "0.96.0" diff --git a/pyproject.toml b/pyproject.toml index cece2e1a0..30f4c1650 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.95.0" +version = "0.96.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 e215e3e41..7d8a2c946 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -39,11 +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). +# ADR 0013: one Demo Corp capture, many runs (lineage + TEPP + report). 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" +DEMO_REPORT_IDEMPOTENCY_KEY = "demo-report-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. @@ -356,6 +357,11 @@ def seed( account_ids["demo.analyst"], corporate_entity_id, ) + _seed_demo_report_run( + cur, + account_ids["demo.analyst"], + corporate_entity_id, + ) conn.commit() finally: @@ -1235,9 +1241,9 @@ def demo_source_snapshot_sha256() -> str: 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. + Lineage, TEPP, and period-report 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( @@ -1555,6 +1561,76 @@ def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> No _seed_demo_run_outbox(cur, run_id) +def _seed_demo_report_run(cur, requested_by_account_id, corporate_entity_id) -> None: + """Record the already-built Demo Corp period report on the shared snapshot. + + ``_seed_demo_period_report`` persists calibrated report tables first. + This registry row is Succeeded because that write already happened. + It does not copy a theta onto ``analysis_run``, does not invent a + local psychometric substitute, and does not enqueue start outbox + work (ADR 0024). Start stays 422. + """ + snapshot_id = _ensure_demo_source_snapshot(cur) + _ensure_demo_source_counts(cur, snapshot_id) + _ensure_demo_source_snapshot_members(cur, snapshot_id, corporate_entity_id) + cur.execute( + """ + select analysis_run_id from analysis_run + where requested_by_account_id = %s + and idempotency_key = %s + """, + (requested_by_account_id, DEMO_REPORT_IDEMPOTENCY_KEY), + ) + 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_report', %s, + %s, '2026-01-12T12:00:00Z', 'report-run-v1', %s, %s, + '2026-01-12T12:38:00Z') + returning analysis_run_id + """, + ( + snapshot_id, + DEMO_REPORT_IDEMPOTENCY_KEY, + requested_by_account_id, + "f" * 64, + "a" * 40, + ), + ) + run_id = cur.fetchone()[0] + else: + run_id = run_row[0] + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, corporate_entity_id) + values (%s, 'analysis_scope_corporate_entity', %s) + on conflict (analysis_run_id) do nothing + """, + (run_id, corporate_entity_id), + ) + for ordinal, status, occurred in ( + (1, "analysis_status_pending", "2026-01-12T12:39:00Z"), + (2, "analysis_status_running", "2026-01-12T12:40:00Z"), + (3, "analysis_status_succeeded", "2026-01-12T12:41:00Z"), + ): + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values (%s, %s, %s, %s) + on conflict do nothing + """, + (run_id, ordinal, status, occurred), + ) + + def _seed_demo_run_outbox(cur, analysis_run_id) -> None: """Record a delivered start-work item for the seeded run. diff --git a/tests/test_seed_report_run.py b/tests/test_seed_report_run.py new file mode 100644 index 000000000..f18840550 --- /dev/null +++ b/tests/test_seed_report_run.py @@ -0,0 +1,87 @@ +"""Seeded period-report analysis runs record the built report, never a theta.""" + +import inspect + +from scripts.seed_demo_data import ( + DEMO_REPORT_IDEMPOTENCY_KEY, + _seed_demo_report_run, + seed, +) + + +class _ReportSeedCursor: + """Drive ``_seed_demo_report_run`` without a live database.""" + + def __init__(self) -> None: + self.statements: list[str] = [] + self.params: list[object] = [] + + def execute(self, sql: str, params=None) -> None: + self.statements.append(" ".join(sql.split())) + self.params.append(params) + + def fetchone(self): + last = self.statements[-1] + if last.lstrip().startswith("select") and "from analysis_source_snapshot" in last: + return None + if "insert into analysis_source_snapshot" in last: + return ("snapshot-demo",) + if last.lstrip().startswith("select") and "from analysis_source_count" in last: + return None + if last.lstrip().startswith("select") and "from analysis_run" in last: + return None + if "insert into analysis_run" in last: + return ("run-demo-report",) + return None + + +def test_seed_calls_report_run_after_period_report_tables() -> None: + """``seed()`` must persist scored tables before the Succeeded registry row.""" + source = inspect.getsource(seed) + period_at = source.index("_seed_demo_period_report(") + report_at = source.index("_seed_demo_report_run(") + assert period_at < report_at + assert "theta" not in source[period_at:report_at].lower() + assert "θ" not in source[period_at:report_at] + + +def test_seed_demo_report_run_inserts_succeeded_report_without_a_theta() -> None: + cursor = _ReportSeedCursor() + _seed_demo_report_run(cursor, "account-1", "corp-1") + run_inserts = [ + (sql, params) + for sql, params in zip(cursor.statements, cursor.params, strict=True) + if "insert into analysis_run" in sql + ] + assert run_inserts, "seed must insert the period-report analysis_run row" + sql, params = run_inserts[0] + assert "analysis_run_report" in sql + assert params is not None + assert DEMO_REPORT_IDEMPOTENCY_KEY in params + assert "report-run-v1" in sql + assert not any( + isinstance(value, str) and ("theta" in value.lower() or "θ" in value) + for value in params + ) + status_params = [ + params + for sql, params in zip(cursor.statements, cursor.params, strict=True) + if "insert into analysis_run_status_event" in sql + ] + assert any( + event_params is not None and "analysis_status_succeeded" in event_params + for event_params in status_params + ) + assert not any( + event_params is not None and "analysis_status_failed" in event_params + for event_params in status_params + ) + assert not any( + event_params is not None + and any( + isinstance(value, str) and "theta" in value.lower() + for value in event_params + ) + for event_params in status_params + ) + assert not any("analysis_run_outbox" in sql for sql in cursor.statements) diff --git a/uv.lock b/uv.lock index e01dbe0b9..5825844b7 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.95.0" +version = "0.96.0" source = { virtual = "." } dependencies = [ { name = "certifi" },