diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf19c5f77..48cc32e76 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 after affiliated +corps load (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.87.1-analysis-run-lineage-only.md b/CHANGELOG.d/0.87.1-analysis-run-lineage-only.md new file mode 100644 index 000000000..93ef7b476 --- /dev/null +++ b/CHANGELOG.d/0.87.1-analysis-run-lineage-only.md @@ -0,0 +1,7 @@ +# 0.87.1 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 and wait until affiliated corps load; choose a corp if you walk +more than one, then click Request a lineage reconstruction. Preview +the picker in Storybook (`Analysis/LineageEntityPicker`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a19fe92..0c37463ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.87.1] - 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 and wait until affiliated corps + load; choose a corp if you walk more than one, then click + **Request a lineage reconstruction**. Preview the picker in + Storybook (`Analysis/LineageEntityPicker`). A failed lineage row + names that button. + ## [0.87.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 870c77f87..e06071e1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ 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.87.1) `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 @@ -31,5 +31,8 @@ lineage row says reconstruction has not started yet. 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. The Request button waits +until affiliated corps load; choose a corp if the token walks more +than one. 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 3b74c22a3..4163d8241 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -546,12 +546,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", }, ) @@ -581,6 +607,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 500c2bc2a..5aa9319b4 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -51,9 +51,10 @@ A pending or running TEPP row must not claim a calibrated measurement. A pending lineage row says reconstruction has not started yet. 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..3058d2091 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,66 @@ 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 + alt TEPP, report, or unknown kind + API-->>Operator: 422 next-action (no registry write) + else same account+key+digest + API->>Registry: compare configuration digest + Registry-->>API: existing run + API-->>Operator: 201 replay + else same key, different digest + API-->>Operator: 409 conflict + else lineage kind, new key + API->>Registry: capture authorized cutoff bag + Registry->>Registry: snapshot + counts + run + scope + pending + API-->>Operator: 201 Pending row + end +``` + +The home panel's **Request a lineage reconstruction** button stays +disabled until `GET /api/me` returns affiliated corps, then records +that Pending row for the chosen entity. 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 sees the corp picker before the Request + button enables, then 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 +85,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 c776053b1..d2ee2a2d0 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -14,7 +14,7 @@ | 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, `invoking_session_role` on each retention event, and exclusion of raw source/provider payloads. | | NIST SP 800-53 Rev. 5 AC-3 | Enforce least privilege on privileged procedures; a well-known procedure name is not an authorization secret. | `REVOKE ALL` on `purge_analysis_run_registry` from `PUBLIC`; `GRANT EXECUTE` only to `analysis_run_retention_admin`; unrevoked `analysis_run_retention_grant` required (ADR 0020). | -| 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/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md index 2f0647dca..7ddfe80a8 100644 --- a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md +++ b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md @@ -8,7 +8,7 @@ the Storybook inventory. | Source | Product implication | Implemented evidence | |---|---|---| -| W3C Design Tokens Format Module 1.0 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--radius-chip`, and `--font-*`. `CitationChip` and `PopupCloseButton` read those names through `App.css`. | +| W3C Design Tokens Format Module 1.0 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--size-control-min`, `--radius-chip`, `--radius-control`, and `--font-*`. `CitationChip`, `PopupCloseButton`, and `LineageEntityPicker` read those names through `App.css`. | | Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. | ## APA 7th references diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 282e3515e..46583abce 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -7,6 +7,7 @@ buyer-facing control you can click before changing product CSS. |---|---|---| | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | +| `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module under `frontend/src/components/`. Do not add a second Node package manager; diff --git a/frontend/package.json b/frontend/package.json index 0d43d9fa2..25956961a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.87.0", + "version": "0.87.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 5251e69f8..c6750f1eb 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -209,10 +209,28 @@ display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; gap: 0.75rem; margin-bottom: 0.75rem; } +.lineage-entity-picker { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-control-gap); + font-size: var(--lw-font-size-meta); +} + +.lineage-entity-picker select { + min-height: var(--size-control-min); + min-width: 12rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + background: var(--color-background); + color: var(--color-text-heading); +} + .lineage-dag-group { margin: 0 0 1.25rem; } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fd8a15146..394d32faa 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -63,8 +63,11 @@ describe("App, authenticated", () => { failedReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; + pluralAffiliations?: boolean; + deferMe?: boolean; + meFailed?: boolean; postBody?: string; - }) { + }): ReturnType & { releaseMe: () => void } { const statusLabel: Record = { open: "Open", in_progress: "In progress", @@ -87,18 +90,37 @@ describe("App, authenticated", () => { let nextEventId = 1; let createdPendingLineage: Record | null = null; + let releaseMe = () => {}; + const meReady = options?.deferMe + ? new Promise((resolve) => { + releaseMe = resolve; + }) + : Promise.resolve(); + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const method = init?.method ?? "GET"; if (url.endsWith("/api/me")) { - return Promise.resolve( - jsonResponse({ + return meReady.then(() => { + if (options?.meFailed) { + return new Response(JSON.stringify({ detail: "unavailable" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + return jsonResponse({ 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" }], + }); + }); } if (url.endsWith("/api/lineage/rebuild") && method === "POST") { return Promise.resolve(jsonResponse({ edge_count: 4 })); @@ -1068,7 +1090,7 @@ describe("App, authenticated", () => { return Promise.reject(new Error(`unexpected fetch: ${method} ${url}`)); }); vi.stubGlobal("fetch", fetchMock); - return fetchMock; + return Object.assign(fetchMock, { releaseMe }); } it("renders the A-100 fork as a git-style DAG, not a flat edge list", async () => { @@ -1731,7 +1753,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( @@ -1819,11 +1841,71 @@ 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("does not record a lineage run before affiliated corps load", async () => { + const fetchMock = stubBackend({ deferMe: true, pluralAffiliations: true }); + render(); + + const loading = await screen.findByRole("button", { name: "Loading affiliated entities..." }); + expect(loading).toBeDisabled(); + await userEvent.click(loading); + expect( + fetchMock.mock.calls.some( + (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", + ), + ).toBe(false); + expect(screen.queryByRole("combobox", { name: "Corporate entity to reconstruct" })).toBeNull(); + + fetchMock.releaseMe(); + expect( + await screen.findByRole("button", { name: "Request a lineage reconstruction" }), + ).toBeEnabled(); + expect( + await screen.findByRole("combobox", { name: "Corporate entity to reconstruct" }), + ).toBeInTheDocument(); + }); + + it("keeps Request disabled when affiliated corps fail to load", async () => { + const fetchMock = stubBackend({ meFailed: true }); + render(); + + expect( + await screen.findByText("Reload to load the corporate entities this account may reconstruct."), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reload to choose a corporate entity" })).toBeDisabled(); + expect( + fetchMock.mock.calls.some( + (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", + ), + ).toBe(false); + }); + 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 07088e9d4..d32c2d81a 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, @@ -59,6 +60,7 @@ import { type VocEvidence, } from "./api"; import { CitationChip } from "./components/CitationChip"; +import { LineageEntityPicker } from "./components/LineageEntityPicker"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; @@ -1474,7 +1476,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: { @@ -1613,14 +1615,28 @@ function AnalysisRunReproducibilityDigests({ function AnalysisRunsPanel({ accessToken, onSelectPost, + corporateEntities, + entitiesLoadError, }: { accessToken: string; onSelectPost: (postId: string) => void; + corporateEntities: CorporateEntityRef[] | null; + entitiesLoadError: string | null; }) { const [runs, setRuns] = useState(null); const [selected, setSelected] = useState(null); const [error, setError] = useState(null); const [requesting, setRequesting] = useState(false); + const [selectedEntityId, setSelectedEntityId] = useState(""); + const inFlightKeyRef = useRef(null); + const entitiesReady = corporateEntities !== null && entitiesLoadError === null; + const requestLabel = requesting + ? "Recording the run..." + : entitiesLoadError + ? "Reload to choose a corporate entity" + : corporateEntities === null + ? "Loading affiliated entities..." + : "Request a lineage reconstruction"; useEffect(() => { fetchAnalysisRuns(accessToken) @@ -1628,19 +1644,49 @@ function AnalysisRunsPanel({ .catch((err) => setError(String(err))); }, [accessToken]); + useEffect(() => { + if (!corporateEntities?.length) { + return; + } + setSelectedEntityId((current) => current || corporateEntities[0].corporate_entity_id); + }, [corporateEntities]); + async function handleRequestLineage() { + if (corporateEntities === null || entitiesLoadError) { + setError( + entitiesLoadError ?? "Reload to load the corporate entities this account may reconstruct.", + ); + return; + } + 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); } @@ -1670,16 +1716,26 @@ function AnalysisRunsPanel({

Analysis runs

+
- {error &&

{error}

} + {(error || entitiesLoadError) &&

{error ?? entitiesLoadError}

} {runs.length === 0 ? (

No analysis runs visible to this account yet. Request a lineage @@ -2029,13 +2085,23 @@ function PostList({ accessToken }: { accessToken: string }) { const [canRebuild, setCanRebuild] = useState(false); const [rebuilding, setRebuilding] = useState(false); const [rebuildError, setRebuildError] = useState(null); + const [corporateEntities, setCorporateEntities] = useState(null); + const [entitiesLoadError, setEntitiesLoadError] = useState(null); useEffect(() => { fetchPosts(accessToken).then(setPosts).catch((err) => setError(String(err))); fetchLineageGraph(accessToken).then(setGraph).catch(() => setGraph({ nodes: [], edges: [] })); fetchMe(accessToken) - .then((me) => setCanRebuild(me.permission_codes.includes("post_admin"))) - .catch(() => setCanRebuild(false)); + .then((me) => { + setCanRebuild(me.permission_codes.includes("post_admin")); + setCorporateEntities(me.corporate_entities ?? []); + setEntitiesLoadError(null); + }) + .catch(() => { + setCanRebuild(false); + setCorporateEntities([]); + setEntitiesLoadError("Reload to load the corporate entities this account may reconstruct."); + }); }, [accessToken]); async function handleRebuild() { @@ -2058,7 +2124,12 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> - +

diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3385d5179..69290bbb7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -234,10 +234,16 @@ export function fetchLineageGraph(accessToken: string): Promise { return backendFetch("/api/lineage", accessToken); } +export interface CorporateEntityRef { + corporate_entity_id: string; + entity_name: string; +} + export interface CurrentUser { user_account_id: string; display_name: string; permission_codes: string[]; + corporate_entities?: CorporateEntityRef[]; } export function fetchMe(accessToken: string): Promise { diff --git a/frontend/src/components/LineageEntityPicker.stories.tsx b/frontend/src/components/LineageEntityPicker.stories.tsx new file mode 100644 index 000000000..6fd287b3b --- /dev/null +++ b/frontend/src/components/LineageEntityPicker.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { LineageEntityPicker } from "./LineageEntityPicker"; + +const demoEntities = [ + { corporate_entity_id: "corp-demo", entity_name: "Demo Corp" }, + { corporate_entity_id: "corp-north", entity_name: "Northridge Grid" }, +]; + +const meta = { + title: "Analysis/LineageEntityPicker", + component: LineageEntityPicker, + args: { + entities: demoEntities, + selectedEntityId: "corp-demo", + onSelectEntityId: () => undefined, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const TwoAffiliations: Story = { + render: function TwoAffiliationsStory(args) { + const [selectedEntityId, setSelectedEntityId] = useState(args.selectedEntityId); + return ( + + ); + }, +}; + +export const SingleAffiliationHidden: Story = { + args: { + entities: [{ corporate_entity_id: "corp-demo", entity_name: "Demo Corp" }], + }, +}; diff --git a/frontend/src/components/LineageEntityPicker.tsx b/frontend/src/components/LineageEntityPicker.tsx new file mode 100644 index 000000000..4b467538d --- /dev/null +++ b/frontend/src/components/LineageEntityPicker.tsx @@ -0,0 +1,41 @@ +export type LineageEntityOption = { + corporate_entity_id: string; + entity_name: string; +}; + +export type LineageEntityPickerProps = { + entities: LineageEntityOption[]; + selectedEntityId: string; + onSelectEntityId: (entityId: string) => void; +}; + +/** + * Chooses which affiliated corp a lineage request will cover. + * + * Next action: pick the entity, then click Request a lineage reconstruction. + */ +export function LineageEntityPicker({ + entities, + selectedEntityId, + onSelectEntityId, +}: LineageEntityPickerProps) { + if (entities.length <= 1) { + return null; + } + return ( + + ); +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index e3510b83c..7b79de43d 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -12,7 +12,10 @@ --space-chip-block: 0.1rem; --space-chip-gap: 0.3rem; --space-close-inset: 0.75rem; + --space-control-gap: 0.35rem; + --size-control-min: 24px; --radius-chip: 999px; + --radius-control: 8px; --font-size-close: 1.5rem; --font-family-chip: ui-monospace, Consolas, monospace; } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1950c39f8..6d22286fa 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.87.0" +__version__ = "0.87.1" diff --git a/pyproject.toml b/pyproject.toml index ecfe24877..9dea9d3f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.87.0" +version = "0.87.1" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_analysis_run_create.py b/tests/test_analysis_run_create.py index 4e24a4228..4c2fa71db 100644 --- a/tests/test_analysis_run_create.py +++ b/tests/test_analysis_run_create.py @@ -1,10 +1,13 @@ """Authorized analysis-run create hashes the cutoff bag, never a score.""" +import asyncio from datetime import datetime, timezone from backend.app.analysis_run_ingestion import ( AnalysisRunCreateError, + _require_lineage_create_kind, _resolve_corporate_entity_id, + create_pending_analysis_run, plan_analysis_run_capture, ) import pytest @@ -124,6 +127,48 @@ def test_empty_corpus_uses_the_cutoff_as_latest_available_time() -> None: assert capture.maximum_available_time == _CUTOFF +def test_create_rejects_tepp_and_report_kinds_without_a_fake_score() -> None: + """POST must not record a TEPP row that never called tepp_client.""" + with pytest.raises(AnalysisRunCreateError) as tepp: + _require_lineage_create_kind("analysis_run_tepp") + assert tepp.value.status_code == 422 + assert "invent a measurement" in tepp.value.detail + with pytest.raises(AnalysisRunCreateError) as report: + _require_lineage_create_kind("analysis_run_report") + assert report.value.status_code == 422 + assert "Reports panel" in report.value.detail + with pytest.raises(AnalysisRunCreateError) as unknown: + _require_lineage_create_kind("analysis_run_unknown") + assert unknown.value.status_code == 422 + assert "Only lineage reconstruction" in unknown.value.detail + _require_lineage_create_kind("analysis_run_lineage") + + +def test_create_pending_rejects_tepp_before_touching_the_registry() -> None: + """Kind rejection happens before any snapshot or run insert.""" + + class ForbiddenConnection: + def __getattr__(self, name: str) -> object: + raise AssertionError(f"TEPP create must not touch the registry ({name})") + + async def _run() -> None: + with pytest.raises(AnalysisRunCreateError) as err: + await create_pending_analysis_run( + ForbiddenConnection(), # type: ignore[arg-type] + account_id="acct-1", + affiliated_entity_ids=["corp-1"], + run_kind_code="analysis_run_tepp", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=_CUTOFF, + idempotency_key="client-key-1", + ) + assert err.value.status_code == 422 + assert "invent a measurement" in err.value.detail + + asyncio.run(_run()) + + def test_create_rejects_an_unaffiliated_or_ambiguous_corporate_entity() -> None: with pytest.raises(AnalysisRunCreateError) as hidden: _resolve_corporate_entity_id("corp-other", ["corp-1"]) diff --git a/uv.lock b/uv.lock index 6915a3531..5b68fe2ee 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.87.0" +version = "0.87.1" source = { virtual = "." } dependencies = [ { name = "certifi" },