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 bedeba285..b662b00b1 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -465,16 +465,25 @@ 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` 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
new file mode 100644
index 000000000..080cc8240
--- /dev/null
+++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md
@@ -0,0 +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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d0edb5d09..22f4878b4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,20 @@ 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` 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
### Fixed
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/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..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({
@@ -236,6 +281,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 +1438,10 @@ 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(
+ "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 ");
@@ -1396,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