diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1fcd327a8..a7f6dfebd 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -507,9 +507,13 @@ 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`.
+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.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.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 a687e410c..4134cc94d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,25 @@ 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
+
+- 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/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/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/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/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/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 3b2e21acb..85873e815 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.94.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 de5a6367b..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",
+ },
+ ],
}),
);
}
@@ -371,6 +396,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",
@@ -626,7 +665,7 @@ describe("App, authenticated", () => {
},
],
},
- ...(options?.failedReportRun
+ ...(options?.failedReportRun || options?.succeededReportRun
? [
{
analysis_run_id: "run-demo-report",
@@ -635,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: [
@@ -1869,6 +1910,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 +1943,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(
@@ -2040,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(
{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..6d722c46b 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.94.0" +__version__ = "0.96.0" diff --git a/pyproject.toml b/pyproject.toml index d3682e502..30f4c1650 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.94.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 499690dbf..5825844b7 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.94.0" +version = "0.96.0" source = { virtual = "." } dependencies = [ { name = "certifi" },