From f2495ab841626ac90f781000bec63e14dc0621d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:27:36 +0000 Subject: [PATCH 1/3] fix: keep analysis-run create lineage-only (v0.86.3) POST /api/analysis-runs already captured a live cutoff bag. Accepting a TEPP kind recorded Pending without calling tepp_client. Reject TEPP and period-report kinds with a next-action 422. Failed lineage copy names Request a lineage reconstruction. GET /api/me lists affiliated corps so a multi-affiliation operator can choose which entity to cover. Rebased onto the current #141 tip so #128 kind/status unions stay. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 12 ++-- .../0.86.3-analysis-run-lineage-only.md | 6 ++ CHANGELOG.md | 10 ++++ CLAUDE.md | 7 ++- backend/app/analysis_run_ingestion.py | 44 ++++++++++---- backend/app/main.py | 42 ++++++++++--- backend/tests/test_api.py | 31 +++++++++- docs/adr/0014-authorized-analysis-run-read.md | 7 ++- .../0017-authorized-analysis-run-create.md | 56 ++++++++++++++--- .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 2 +- frontend/package.json | 2 +- frontend/src/App.css | 1 + frontend/src/App.test.tsx | 31 +++++++++- frontend/src/App.tsx | 60 ++++++++++++++++++- frontend/src/api.ts | 6 ++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_analysis_run_create.py | 45 ++++++++++++++ uv.lock | 2 +- 19 files changed, 321 insertions(+), 47 deletions(-) create mode 100644 CHANGELOG.d/0.86.3-analysis-run-lineage-only.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b66c7cde7..38b88613c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -471,11 +471,13 @@ 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. Detail also returns revision and configuration digest prefixes. -`POST /api/analysis-runs` records a Pending run on a new authorized -cutoff capture (ADR 0017): snapshot, counts, run, scope, and the first -status in one transaction. It does not reconstruct lineage and does not -invent a TEPP score. Request a lineage reconstruction from the home -list, then open the Pending row to confirm the cutoff corpus. +`POST /api/analysis-runs` records a Pending lineage run on a new +authorized cutoff capture (ADR 0017): snapshot, counts, run, scope, and +the first status in one transaction. TEPP and period-report kinds are +422. It does not reconstruct lineage and does not invent a TEPP score. +Request a lineage reconstruction from the home list (choose a corp if +you walk more than one), then open the Pending row to confirm the +cutoff corpus. `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. diff --git a/CHANGELOG.d/0.86.3-analysis-run-lineage-only.md b/CHANGELOG.d/0.86.3-analysis-run-lineage-only.md new file mode 100644 index 000000000..56f4063da --- /dev/null +++ b/CHANGELOG.d/0.86.3-analysis-run-lineage-only.md @@ -0,0 +1,6 @@ +# 0.86.3 Analysis-run write is lineage-only + +`POST /api/analysis-runs` records Pending lineage on an authorized +cutoff capture. TEPP and period-report kinds are 422. Open Analysis +runs, choose a corp if you walk more than one, then click Request a +lineage reconstruction. diff --git a/CHANGELOG.md b/CHANGELOG.md index 13aeb02f9..346efe2e1 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.86.3] - 2026-08-16 + +### Fixed + +- `POST /api/analysis-runs` records Pending lineage only (ADR 0017). + TEPP and period-report kinds are 422 so this path cannot invent a + measurement. Open Analysis runs, choose a corp if you walk more than + one, then click **Request a lineage reconstruction**. A failed + lineage row names that button. + ## [0.86.2] - 2026-08-16 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index a11127584..09102e916 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.85.0) +## Analysis-run seed (v0.86.3) `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 @@ -18,5 +18,6 @@ pending TEPP row does not claim a calibrated measurement. Digest prefixes stay audible; hover a prefix to read the full digest. Opening a cutoff title shows the live post -- compare it with the cutoff before treating the body as reconstructed evidence (ADR 0016). -`POST /api/analysis-runs` records Pending on an authorized -cutoff capture (ADR 0017) and does not reconstruct lineage. +`POST /api/analysis-runs` records Pending lineage only on an +authorized cutoff capture (ADR 0017). TEPP and period-report kinds +are 422. It does not invent a TEPP theta. diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index d26eb6f6e..199bcc8c3 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -7,8 +7,8 @@ payloads never do. ``create_pending_analysis_run`` (ADR 0017) writes snapshot, counts, run, -scope, and the first Pending event atomically. It does not reconstruct -lineage or invent a TEPP score. +scope, and the first Pending event atomically. It records lineage only. +It does not reconstruct lineage, accept a TEPP kind, or invent a score. """ from __future__ import annotations @@ -25,7 +25,9 @@ from backend.app.knowledge_graph import labels_for_codes from lineageweave import __version__ as PACKAGE_VERSION -_ALLOWED_CREATE_KINDS = frozenset({"analysis_run_lineage", "analysis_run_tepp"}) +_LINEAGE_RUN_KIND = "analysis_run_lineage" +_TEPP_RUN_KIND = "analysis_run_tepp" +_REPORT_RUN_KIND = "analysis_run_report" _CORPORATE_SCOPE = "analysis_scope_corporate_entity" _CAPTURE_CONTRACT_VERSION = "analysis-run-capture-v1" _KIND_SCHEMA_VERSION = { @@ -319,6 +321,31 @@ def __init__(self, status_code: int, detail: str) -> None: self.detail = detail +def _require_lineage_create_kind(run_kind_code: str) -> None: + """Reject TEPP and report writes so this path cannot fake those products. + + TEPP stays a ``tepp_client`` wire path. Period reports stay on the + Reports panel rebuild. A Pending TEPP row that never called the + transport is a fabricated measurement request. + """ + if run_kind_code == _TEPP_RUN_KIND: + raise AnalysisRunCreateError( + 422, + "Connect a TEPP transport from a Failed TEPP row; this endpoint " + "does not invent a measurement.", + ) + if run_kind_code == _REPORT_RUN_KIND: + raise AnalysisRunCreateError( + 422, + "Rebuild the period report from the Reports panel.", + ) + if run_kind_code != _LINEAGE_RUN_KIND: + raise AnalysisRunCreateError( + 422, + "Only lineage reconstruction can be requested here.", + ) + + @dataclass(frozen=True) class AnalysisRunCapture: """Immutable capture plan for one authorized create (no source rows).""" @@ -443,15 +470,10 @@ async def create_pending_analysis_run( ) -> dict[str, Any]: """Insert snapshot, counts, run, scope, and Pending in one transaction. - Does not reconstruct lineage and does not call TEPP. A missing - measurement stays a later worker slice; this write only records the - request. Idempotent retries compare ``configuration_sha256``. + Lineage only. Does not reconstruct, call TEPP, or invent a theta. + Idempotent retries compare ``configuration_sha256``. """ - if run_kind_code not in _ALLOWED_CREATE_KINDS: - raise AnalysisRunCreateError( - 422, - "Request a lineage reconstruction or a TEPP measurement. Other kinds are not available yet.", - ) + _require_lineage_create_kind(run_kind_code) if scope_kind_code != _CORPORATE_SCOPE: raise AnalysisRunCreateError( 422, diff --git a/backend/app/main.py b/backend/app/main.py index adb7a20a8..d7d2621b2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -312,12 +312,39 @@ async def healthz() -> dict[str, str]: @app.get("/api/me") -async def read_me(account: CurrentAccount = Depends(get_current_account)) -> dict[str, Any]: - """Return the provisioned account that the bearer token resolved to.""" +async def read_me( + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Return the provisioned account and the corps this token may walk. + + Multi-affiliation operators need those names to choose which entity + ``POST /api/analysis-runs`` should cover. + """ + entities: list[dict[str, str]] = [] + if account.corporate_entity_ids: + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + select corporate_entity_id, entity_name + from corporate_entity + where corporate_entity_id = any($1::uuid[]) + order by entity_name + """, + list(account.corporate_entity_ids), + ) + entities = [ + { + "corporate_entity_id": str(row["corporate_entity_id"]), + "entity_name": row["entity_name"], + } + for row in rows + ] return { "user_account_id": account.user_account_id, "display_name": account.display_name, "permission_codes": sorted(account.permission_codes), + "corporate_entities": entities, } @@ -1211,8 +1238,8 @@ class CreateAnalysisRunRequest(BaseModel): """JSON body for ``POST /api/analysis-runs``. Omitting ``corporate_entity_id`` uses the account's sole affiliation. - Reconstruction and TEPP execution stay later slices; this write - records Pending only. + Only ``analysis_run_lineage`` is accepted. Reconstruction and TEPP + execution stay later slices; this write records Pending lineage only. """ run_kind_code: str = "analysis_run_lineage" @@ -1228,11 +1255,12 @@ async def create_analysis_run( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """Record a Pending analysis run on an authorized cutoff capture. + """Record a Pending lineage run on an authorized cutoff capture. post_read is enough: the caller requests a run of a corp they - already walk. The payload is the same authorized detail as GET. - Hidden scopes 404. A matching idempotent retry returns the same run. + already walk. TEPP and period-report kinds are 422 so this path + cannot invent a measurement. Hidden scopes 404. A matching + idempotent retry returns the same run. """ _require_post_read(account) async with pool.acquire() as conn: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 21c71bc9a..d3fd1092f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -544,12 +544,38 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( assert replay.status_code == 201 assert replay.json()["analysis_run_id"] == body["analysis_run_id"] - conflict = client.post( + tepp = client.post( "/api/analysis-runs", headers={"Authorization": f"Bearer {demo_analyst_token}"}, json={ "run_kind_code": "analysis_run_tepp", "corporate_entity_id": seeded_db["own_corp_id"], + "idempotency_key": "buyer-create-tepp", + }, + ) + assert tepp.status_code == 422 + assert "invent a measurement" in tepp.json()["detail"] + assert "theta" not in tepp.json()["detail"].lower() + + report = client.post( + "/api/analysis-runs", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + json={ + "run_kind_code": "analysis_run_report", + "corporate_entity_id": seeded_db["own_corp_id"], + "idempotency_key": "buyer-create-report", + }, + ) + assert report.status_code == 422 + assert "Reports panel" in report.json()["detail"] + + conflict = client.post( + "/api/analysis-runs", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + json={ + "run_kind_code": "analysis_run_lineage", + "corporate_entity_id": seeded_db["own_corp_id"], + "knowledge_cutoff": "2026-01-01T00:00:00Z", "idempotency_key": "buyer-create-2026-w02", }, ) @@ -579,6 +605,9 @@ def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> No body = response.json() assert body["display_name"] == "Test Analyst" assert "post_read" in body["permission_codes"] + assert any( + entity["entity_name"] == "Test Corp" for entity in body["corporate_entities"] + ) def test_post_list_includes_public_and_own_corp_but_excludes_other_corp(client, demo_analyst_token, seeded_db) -> None: diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 29c074c8f..49899be2c 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -50,9 +50,10 @@ tells the operator to rebuild the report from a current snapshot. A pending or running TEPP row must not claim a calibrated measurement. The detail now shows the legal lifecycle the registry already stored. `POST /api/analysis-runs` now -records a Pending run on an authorized cutoff capture (ADR 0017). -Reconstruction, a live TEPP transport, and a fuller Analysis Run -Console remain later slices. +records a Pending lineage run on an authorized cutoff capture +(ADR 0017). TEPP and period-report kinds are 422. Reconstruction, a +live TEPP transport, and a fuller Analysis Run Console remain later +slices. ## References diff --git a/docs/adr/0017-authorized-analysis-run-create.md b/docs/adr/0017-authorized-analysis-run-create.md index e3a535a18..13ccc4cf8 100644 --- a/docs/adr/0017-authorized-analysis-run-create.md +++ b/docs/adr/0017-authorized-analysis-run-create.md @@ -1,4 +1,4 @@ -# ADR 0017 — Operators request an analysis run through the product API +# ADR 0017 — Operators request a pending lineage run on an authorized capture **Decision status:** Accepted on this active PR; not protected-main truth until merge **Date:** 2026-08-16 @@ -14,30 +14,63 @@ ADR 0013 already required a transaction that creates snapshot, counts, run, scope, and the first status atomically. Follow-up 3 (outbox / worker) still owns reconstruction and live TEPP execution. +`#125` landed that write and also accepted a TEPP kind. A Pending TEPP +row that never called `tepp_client` is a fabricated measurement request. +This decision keeps the live cutoff capture and closes that hole. + ## Decision `POST /api/analysis-runs` is the authorized write: - `post_read` is enough. The caller may only cover a corporate entity they already walk. An unaffiliated corp is 404, not 403. +- 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. - The capture digest hashes scope, entity, cutoff, and authorized post - ids — never a post body, DSN, or source SQL. + ids — never a post body, DSN, source SQL, or a theta. - The write inserts snapshot, aggregate counts, `analysis_run`, `analysis_run_scope`, and `analysis_status_pending` in one transaction. - The first status is Pending. This slice does not reconstruct lineage - and does not call TEPP. A missing measurement stays Failed only on the - seed path that already goes through `tepp_client`. + and does not call TEPP. - Account-scoped idempotency compares `configuration_sha256`. An omitted cutoff is hashed as `unspecified` so a retry of the same client key does not conflict because the clock moved. +- `GET /api/me` returns the affiliated `corporate_entities` so a + multi-affiliation operator can choose which entity to reconstruct. - The response is the same authorized detail as `GET /api/analysis-runs/{id}`. +```mermaid +sequenceDiagram + participant Operator + participant API + participant Registry + Operator->>API: POST /api/analysis-runs (lineage, idempotency key) + API->>Registry: capture authorized cutoff bag + alt TEPP or report kind + API-->>Operator: 422 next-action + else same account+key+digest + Registry-->>API: existing run + API-->>Operator: 201 replay + else same key, different digest + API-->>Operator: 409 conflict + else new key + Registry->>Registry: snapshot + counts + run + scope + pending + API-->>Operator: 201 Pending row + end +``` + +The home panel's **Request a lineage reconstruction** button records that +Pending row. A failed lineage row names that button. Only a failed TEPP +row mentions the measurement service. + ## Consequences -The home panel's **Request a lineage reconstruction** button records a -Pending row the operator can open immediately. Reconstruction, TEPP -transport, and the outbox worker remain later slices. Do not stamp -Succeeded or invent a theta from this write. +- Demo Analyst can request a new Pending Demo Corp lineage run after + `make seed` without inventing a measurement. +- A multi-affiliation account chooses the corp before clicking. +- Reconstruction, live TEPP transport, and the outbox worker remain + later slices. Do not stamp Succeeded or invent a theta from this write. ## References — APA 7th @@ -49,8 +82,15 @@ Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. *IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44. https://doi.org/10.1109/69.755613 +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 b41b31c17..a0009f2fd 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -13,7 +13,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 /api/analysis-runs`, `GET /api/analysis-runs/{id}`, and `POST /api/analysis-runs` return the authorized projection (labels, clocks, aggregates). TEPP/report creates are 422. | ## Temporal reasoning diff --git a/frontend/package.json b/frontend/package.json index fb52f7948..b5209226e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.2", + "version": "0.86.3", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index b3fab25d1..5b46a6d92 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -209,6 +209,7 @@ display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; gap: 0.75rem; margin-bottom: 0.75rem; } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 65d25259b..f00be359b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -63,6 +63,7 @@ describe("App, authenticated", () => { failedReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; + pluralAffiliations?: boolean; postBody?: string; }) { const statusLabel: Record = { @@ -96,6 +97,12 @@ describe("App, authenticated", () => { user_account_id: options?.admin ? "acct-admin" : "acct-1", display_name: options?.admin ? "Demo Admin" : "Demo Analyst", permission_codes: options?.admin ? ["post_read", "post_admin"] : ["post_read"], + corporate_entities: options?.pluralAffiliations + ? [ + { corporate_entity_id: "corp-demo", entity_name: "Demo Corp" }, + { corporate_entity_id: "corp-north", entity_name: "Northridge Grid" }, + ] + : [{ corporate_entity_id: "corp-demo", entity_name: "Demo Corp" }], }), ); } @@ -1728,7 +1735,7 @@ describe("App, authenticated", () => { name: "Open analysis run: TEPP measurement · Failed · Demo Corp", }); expect(lineageButton).toHaveTextContent( - "Open this run to see why it failed, then retry reconstruction from a current snapshot.", + "Open this run to see why it failed, then click Request a lineage reconstruction.", ); expect(lineageButton).not.toHaveTextContent("measurement service"); expect(teppButton).toHaveTextContent( @@ -1806,11 +1813,33 @@ describe("App, authenticated", () => { expect(postCall).toBeDefined(); const body = JSON.parse(String(postCall?.[1]?.body)); expect(body.run_kind_code).toBe("analysis_run_lineage"); + expect(body.corporate_entity_id).toBe("corp-demo"); 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("lets a multi-affiliation operator choose which corp to reconstruct", async () => { + const fetchMock = stubBackend({ pluralAffiliations: true }); + render(); + + const picker = await screen.findByRole("combobox", { + name: "Corporate entity to reconstruct", + }); + await userEvent.selectOptions(picker, "corp-north"); + await userEvent.click(screen.getByRole("button", { name: "Request a lineage reconstruction" })); + await waitFor(() => + expect( + fetchMock.mock.calls.some( + (call) => + String(call[0]).endsWith("/api/analysis-runs") && + call[1]?.method === "POST" && + JSON.parse(String(call[1]?.body)).corporate_entity_id === "corp-north", + ), + ).toBe(true), + ); + }); + 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 a511f713d..938e1c7f7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,6 +41,7 @@ import { type CalendarEntry, type ChatAnswer, type ChatExchange, + type CorporateEntityRef, type Counterparty, type EvaluationResponse, type IssueTicket, @@ -1468,7 +1469,7 @@ function analysisRunNextAction(run: AnalysisRun): string | null { 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."; + return "Open this run to see why it failed, then click Request a lineage reconstruction."; case "analysis_run_report": return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; default: { @@ -1605,6 +1606,9 @@ function AnalysisRunsPanel({ const [selected, setSelected] = useState(null); const [error, setError] = useState(null); const [requesting, setRequesting] = useState(false); + const [corporateEntities, setCorporateEntities] = useState([]); + const [selectedEntityId, setSelectedEntityId] = useState(""); + const inFlightKeyRef = useRef(null); useEffect(() => { fetchAnalysisRuns(accessToken) @@ -1612,19 +1616,51 @@ function AnalysisRunsPanel({ .catch((err) => setError(String(err))); }, [accessToken]); + useEffect(() => { + fetchMe(accessToken) + .then((me) => { + const entities = me.corporate_entities ?? []; + setCorporateEntities(entities); + setSelectedEntityId((current) => current || entities[0]?.corporate_entity_id || ""); + }) + .catch(() => { + setCorporateEntities([]); + setError((current) => + current ?? "Reload to load the corporate entities this account may reconstruct.", + ); + }); + }, [accessToken]); + async function handleRequestLineage() { + if (corporateEntities.length > 1 && !selectedEntityId) { + setError("Choose which corporate entity to reconstruct."); + return; + } setError(null); setRequesting(true); + if (inFlightKeyRef.current === null) { + inFlightKeyRef.current = crypto.randomUUID(); + } + const idempotencyKey = inFlightKeyRef.current; try { const created = await createAnalysisRun(accessToken, { run_kind_code: "analysis_run_lineage", - idempotency_key: crypto.randomUUID(), + idempotency_key: idempotencyKey, + ...(selectedEntityId ? { corporate_entity_id: selectedEntityId } : {}), }); const listed = await fetchAnalysisRuns(accessToken); setRuns(listed.analysis_runs); setSelected(created); + inFlightKeyRef.current = null; } catch (err) { - setError(err instanceof BackendError ? err.message : String(err)); + if (err instanceof BackendError && err.status === 409) { + inFlightKeyRef.current = null; + setError( + "This request key already names a different reconstruction. Request again to start a new run.", + ); + } else { + setError(err instanceof BackendError ? err.message : String(err)); + } } finally { setRequesting(false); } @@ -1648,11 +1684,28 @@ function AnalysisRunsPanel({ if (runs === null) return

Loading analysis runs...

; const corpusHint = selected ? analysisRunCorpusHint(selected) : null; + const selectedNextAction = selected ? analysisRunNextAction(selected) : null; return (

Analysis runs

+ {corporateEntities.length > 1 && ( + + )}