From ec3ee31991fd72164a9edb694fec3f25fea742b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 13:20:49 +0000 Subject: [PATCH] fix: wait for affiliated corps before lineage create (v2.7.1) Request stays disabled until GET /api/me returns affiliated corps so a multi-affiliation operator cannot POST before the picker appears. POST /api/analysis-runs records Pending lineage only. TEPP and period-report kinds 422 before any snapshot write. Do not invent a theta. Co-authored-by: Seongho Bae --- AGENTS.md | 4 +- ARCHITECTURE.md | 9 +- ....1-wait-affiliated-corps-lineage-create.md | 7 + CHANGELOG.md | 12 ++ CLAUDE.md | 11 +- backend/app/analysis_run_ingestion.py | 51 ++++-- backend/app/main.py | 42 ++++- backend/tests/test_api.py | 94 ++++++++++- docs/adr/0014-authorized-analysis-run-read.md | 7 +- .../0017-authorized-analysis-run-create.md | 64 ++++++-- docs/adr/0022-authorized-tepp-start.md | 6 +- .../0024-seed-period-report-analysis-run.md | 2 +- .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 2 +- docs/doctoring/DESIGN_TOKEN_REFERENCES.md | 2 +- docs/storybook-inventory.md | 1 + frontend/package.json | 2 +- frontend/src/App.css | 18 +++ frontend/src/App.test.tsx | 153 +++++++++++++----- frontend/src/App.tsx | 116 +++++++++---- frontend/src/api.ts | 6 + .../LineageEntityPicker.stories.tsx | 41 +++++ .../components/LineageEntityPicker.test.tsx | 38 +++++ .../src/components/LineageEntityPicker.tsx | 41 +++++ frontend/src/styles/tokens.css | 3 + lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_analysis_run_create.py | 45 ++++++ uv.lock | 2 +- 28 files changed, 651 insertions(+), 132 deletions(-) create mode 100644 CHANGELOG.d/2.7.1-wait-affiliated-corps-lineage-create.md create mode 100644 frontend/src/components/LineageEntityPicker.stories.tsx create mode 100644 frontend/src/components/LineageEntityPicker.test.tsx create mode 100644 frontend/src/components/LineageEntityPicker.tsx diff --git a/AGENTS.md b/AGENTS.md index 28e61d946..958da2e08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,9 +91,11 @@ A run-bearing analysis-run registry empties only after an unrevoked (ADR 0020 / v0.87.0). The documented phrase is not a secret. Do not expose purge on a public HTTP route. +`POST /api/analysis-runs` records Pending lineage only (ADR 0017 / +v2.7.1). TEPP and period-report kinds 422 before any snapshot write. `POST /api/analysis-runs/{id}/start` reconstructs a Pending lineage cutoff bag through `reconstruct()` / `lineage_edge_specs` (ADR 0021 / -v0.88.0). TEPP and period-report start stay 422. Do not invent a theta. +v0.88.0). Do not invent a theta. Opening a cutoff-rewritten title shows **Body this run knew** from `source_post_revision` beside the live rewrite (ADR 0025 / v2.1.0). Do not invent the earlier sentence when no revision covers the cutoff. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0f2756c15..cda036d11 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -471,9 +471,12 @@ 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, frozen membership, run, -scope, and the first status in one transaction. +`POST /api/analysis-runs` records a Pending lineage run on a new +authorized cutoff capture (ADR 0017): snapshot, counts, frozen +membership, run, scope, and the first status in one transaction. TEPP +and period-report kinds are 422. 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. `POST /api/analysis-runs/{id}/start` then commits Running plus a durable outbox row, wakes Valkey, and delivers ThreadWeave on that frozen bag (ADR 0021 / ADR 0023) or submits TEPP through diff --git a/CHANGELOG.d/2.7.1-wait-affiliated-corps-lineage-create.md b/CHANGELOG.d/2.7.1-wait-affiliated-corps-lineage-create.md new file mode 100644 index 000000000..a5a7aa047 --- /dev/null +++ b/CHANGELOG.d/2.7.1-wait-affiliated-corps-lineage-create.md @@ -0,0 +1,7 @@ +# 2.7.1 Wait for affiliated corps before lineage create + +`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 29fdb26e1..418c8e83a 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). +## [2.7.1] - 2026-08-17 + +### 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`). Failed TEPP stays + terminal on this write. + ## [2.7.0] - 2026-08-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 2cc176764..a5a448323 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,13 +34,16 @@ after cutoff were rewritten after the run; the opened body names both clocks and shows **Body this run knew** beside the live rewrite. Compare those two texts before treating the live body as reconstructed evidence (ADR 0016 / 0025). -`POST /api/analysis-runs` records Pending on an authorized -cutoff capture (ADR 0017). `POST /api/analysis-runs/{id}/start` +`POST /api/analysis-runs` records Pending lineage only on an +authorized cutoff capture (ADR 0017). TEPP and period-report kinds +are 422. The Request button waits until affiliated corps load; choose +a corp if the token walks more than one. `POST /api/analysis-runs/{id}/start` commits Running plus a durable outbox row, then reconstructs that frozen cutoff bag (ADR 0021 / ADR 0023) or submits TEPP through `tepp_client` (ADR 0022). A missing transport or unused accepted -envelope is Failed. Failed TEPP is terminal — request a new run, -then start. Do not invent a theta. Hover the Result prefix to read +envelope is Failed. Failed TEPP is terminal — connect a TEPP +transport from that Failed row. Create does not invent a Pending +TEPP row. Do not invent a theta. Hover the Result prefix to read the parent-choice digest. After `make seed`, open **Period report · Succeeded · Demo Corp**, then **Open period report 2026-W02**. The home week is already diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 8532ee6e1..50eb821a2 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -7,10 +7,12 @@ payloads never do. ``create_pending_analysis_run`` (ADR 0017) writes snapshot, counts, frozen -membership, run, scope, and the first Pending event atomically. -``enqueue_pending_analysis_run`` then ``deliver_queued_analysis_run`` -later reconstruct lineage (ADR 0021 / ADR 0023) or submit TEPP through -``tepp_client`` (ADR 0022). Neither path invents a TEPP score. +membership, run, scope, and the first Pending event atomically. It +records lineage only. It does not reconstruct lineage, accept a TEPP +kind, or invent a score. ``enqueue_pending_analysis_run`` then +``deliver_queued_analysis_run`` later reconstruct lineage (ADR 0021 / +ADR 0023) or submit TEPP through ``tepp_client`` (ADR 0022). Neither +path invents a TEPP score. """ from __future__ import annotations @@ -27,7 +29,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 = { @@ -578,6 +582,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).""" @@ -702,15 +731,11 @@ async def create_pending_analysis_run( ) -> dict[str, Any]: """Insert snapshot, counts, frozen members, run, scope, and Pending. - 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. + Kind rejection happens before any snapshot or run insert. + 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 45c517080..8b6b16932 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -320,12 +320,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, } @@ -1243,8 +1270,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" @@ -1260,11 +1287,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 23726950a..feacfe8d4 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -595,12 +595,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", }, ) @@ -727,7 +753,7 @@ def test_start_analysis_run_recovers_the_a100_fork( assert replay.status_code == 200 assert replay.json()["reconstruction_result_sha256"] == body["reconstruction_result_sha256"] - tepp = client.post( + tepp_create = client.post( "/api/analysis-runs", headers={"Authorization": f"Bearer {demo_analyst_token}"}, json={ @@ -737,9 +763,66 @@ def test_start_analysis_run_recovers_the_a100_fork( "idempotency_key": "buyer-start-tepp-2026-w07", }, ) - assert tepp.status_code == 201 + assert tepp_create.status_code == 422 + assert "invent a measurement" in tepp_create.json()["detail"] + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "select requested_by_account_id from analysis_run where analysis_run_id = %s", + (run_id,), + ) + requester_id = cur.fetchone()[0] + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'source-contract-v1', + '2026-02-15T00:00:00Z', '2026-02-15T00:05:00Z') + returning analysis_source_snapshot_id + """, + ("t" * 64,), + ) + tepp_snapshot_id = cur.fetchone()[0] + 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_tepp', 'buyer-start-tepp-seeded', + %s, '2026-02-15T00:00:00Z', 'tepp-run-v1', %s, %s, + '2026-02-15T12:30:00Z') + returning analysis_run_id + """, + (tepp_snapshot_id, requester_id, "u" * 64, "v" * 40), + ) + tepp_run_id = str(cur.fetchone()[0]) + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, corporate_entity_id) + values (%s, 'analysis_scope_corporate_entity', %s) + """, + (tepp_run_id, seeded_db["own_corp_id"]), + ) + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values (%s, 1, 'analysis_status_pending', '2026-02-15T12:31:00Z') + """, + (tepp_run_id,), + ) + finally: + admin_conn.close() + measured = client.post( - f"/api/analysis-runs/{tepp.json()['analysis_run_id']}/start", + f"/api/analysis-runs/{tepp_run_id}/start", headers={"Authorization": f"Bearer {demo_analyst_token}"}, ) assert measured.status_code == 200, measured.text @@ -957,6 +1040,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 a8d82acbf..10e99d37d 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -52,9 +52,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 da2d5661e..81841c469 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,33 +14,70 @@ 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, frozen `analysis_source_snapshot_member` ids, `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. Only a failed TEPP row +mentions the measurement service. Failed TEPP is terminal on this +write: create does not invent a Pending TEPP row. + ## Consequences -The home panel's **Request a lineage reconstruction** button records a -Pending row the operator can open immediately. `POST -/api/analysis-runs/{id}/start` then reconstructs that frozen bag -(ADR 0021). TEPP start now goes through `tepp_client` (ADR 0022). The -outbox worker is ADR 0023. -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. +- `POST /api/analysis-runs/{id}/start` then reconstructs that frozen bag + (ADR 0021). TEPP start now goes through `tepp_client` (ADR 0022). The + outbox worker is ADR 0023. +- Do not stamp Succeeded or invent a theta from this write. ## References — APA 7th @@ -52,8 +89,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/adr/0022-authorized-tepp-start.md b/docs/adr/0022-authorized-tepp-start.md index bf6e54d5e..84fc71635 100644 --- a/docs/adr/0022-authorized-tepp-start.md +++ b/docs/adr/0022-authorized-tepp-start.md @@ -44,9 +44,9 @@ authorized transaction: Succeeded TEPP stays later. This slice does not persist a local psychometric substitute, does not call contextual-orchestrator as TEPP, and does not stamp Succeeded from an `accepted` envelope. Failed remains -terminal: the detail offers **Request a new TEPP measurement**, which -creates a new Pending run (ADR 0017). The operator then starts that -row. +terminal. `POST /api/analysis-runs` is lineage-only (ADR 0017) and does +not invent a Pending TEPP row. The operator connects a TEPP transport +from the Failed row, then starts that same measurement. ```mermaid sequenceDiagram diff --git a/docs/adr/0024-seed-period-report-analysis-run.md b/docs/adr/0024-seed-period-report-analysis-run.md index 686f89c00..65df43f15 100644 --- a/docs/adr/0024-seed-period-report-analysis-run.md +++ b/docs/adr/0024-seed-period-report-analysis-run.md @@ -37,7 +37,7 @@ on a path that is not allowed to (ADR 0021 / ADR 0022 / ADR 0023). 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` stays lineage-only (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. diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index e1951c9d5..99dfff47b 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -15,7 +15,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. | `GET` / `POST /api/analysis-runs` and `POST /api/analysis-runs/{id}/start` return labels, clocks, aggregates, and titled reconstruction edges — never source SQL or a provider body. | +| OpenAPI 3.2.0 | Define explicit versioned API schemas rather than exposing database rows or implementation-specific payloads. | `GET` / `POST /api/analysis-runs` and `POST /api/analysis-runs/{id}/start` return labels, clocks, aggregates, and titled reconstruction edges — never source SQL or a provider body. TEPP/report creates are 422. | | ThreadWeave tree assembly | Persist the same parent choices the library reconstructs on the cutoff bag. | Start enqueues `analysis_run_outbox` then `deliver_queued_analysis_run` calls `lineage_edge_specs` on frozen `analysis_source_snapshot_member` rows (or the live cutoff query when membership is absent); tests require the designed A-100 fork through `records_from_source_posts` (revised quote + delivery question under the pricing follow-up). TEPP start uses `tepp_client` only. Valkey `analysis-run-outbox` is the wake-up (ADR 0023). | ## Temporal reasoning diff --git a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md index 9201df8e2..ac73b0921 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`, `--radius-panel`, and `--font-*`. `CitationChip`, `PopupCloseButton`, and `CutoffKnownBody` 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`, `--radius-panel`, and `--font-*`. `CitationChip`, `PopupCloseButton`, `CutoffKnownBody`, 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 538b7960f..a535877bc 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` | | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | +| `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module diff --git a/frontend/package.json b/frontend/package.json index 0f6b4069f..f9ababdae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.7.0", + "version": "2.7.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index dd1ed1534..bdda84290 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -236,10 +236,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 dca4697e9..f63c699d6 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -65,8 +65,11 @@ describe("App, authenticated", () => { succeededReportRun?: 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", @@ -90,18 +93,37 @@ describe("App, authenticated", () => { let createdPendingLineage: Record | null = null; let createdPendingTepp: 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 })); @@ -552,31 +574,18 @@ describe("App, authenticated", () => { } if (url.endsWith("/api/analysis-runs") && method === "POST") { const payload = init?.body ? JSON.parse(String(init.body)) : {}; - if (payload.run_kind_code === "analysis_run_tepp") { - const created = { - analysis_run_id: "run-demo-tepp-pending", - 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_pending", - status_label: "Pending", - knowledge_cutoff: "2026-01-12T12:00:00Z", - requested_at: "2026-01-12T12:41:00Z", - source_counts: [], - 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:41:00Z", - }, - ], - }; - createdPendingTepp = created; - return Promise.resolve(new Response(JSON.stringify(created), { status: 201 })); + if (payload.run_kind_code === "analysis_run_tepp" || payload.run_kind_code === "analysis_run_report") { + return Promise.resolve( + new Response( + JSON.stringify({ + detail: + payload.run_kind_code === "analysis_run_tepp" + ? "Connect a TEPP transport from a Failed TEPP row; this endpoint does not invent a measurement." + : "Rebuild the period report from the Reports panel.", + }), + { status: 422, headers: { "Content-Type": "application/json" } }, + ), + ); } const created = { analysis_run_id: "run-demo-lineage-pending", @@ -1389,7 +1398,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 () => { @@ -2511,7 +2520,7 @@ describe("App, authenticated", () => { expect(startCall?.[1]?.method).toBe("POST"); }); - it("requests a new TEPP run from a failed row instead of mutating Failed", async () => { + it("does not invent a Pending TEPP row from a Failed TEPP run", async () => { const fetchMock = stubBackend(); render(); @@ -2520,18 +2529,18 @@ describe("App, authenticated", () => { name: "Open analysis run: TEPP measurement · Failed · Demo Corp", }), ); - await userEvent.click(screen.getByRole("button", { name: "Request a new TEPP measurement" })); expect( - await screen.findByRole("heading", { name: "TEPP measurement · Pending · Demo Corp" }), + await screen.findByText( + "Connect a TEPP transport from this Failed row. Request a lineage reconstruction does not invent a measurement.", + ), ).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Start TEPP measurement" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument(); - const postCall = fetchMock.mock.calls.find( - (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", - ); - expect(postCall).toBeDefined(); - const body = JSON.parse(String(postCall?.[1]?.body)); - expect(body.run_kind_code).toBe("analysis_run_tepp"); + expect(screen.queryByRole("button", { name: "Request a new TEPP measurement" })).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "TEPP measurement · Pending · Demo Corp" })).not.toBeInTheDocument(); + expect( + fetchMock.mock.calls.some( + (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", + ), + ).toBe(false); }); it("does not tell a succeeded TEPP run to replace Failed", async () => { @@ -2576,11 +2585,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("starts reconstruction and shows the designed A-100 fork", async () => { const fetchMock = stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f8aa35dbe..076420785 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,6 +42,7 @@ import { type CalendarEntry, type ChatAnswer, type ChatExchange, + type CorporateEntityRef, type Counterparty, type EvaluationResponse, type IssueTicket, @@ -61,6 +62,7 @@ import { } from "./api"; import { CitationChip } from "./components/CitationChip"; import { CutoffKnownBody } from "./components/CutoffKnownBody"; +import { LineageEntityPicker } from "./components/LineageEntityPicker"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; @@ -1930,7 +1932,7 @@ function analysisRunStartLabel(run: AnalysisRun): string { : "Start reconstruction"; } -/** Failed TEPP is terminal. Re-run records a new Pending TEPP row. */ +/** Failed TEPP is terminal. Create cannot invent a Pending TEPP row. */ function analysisRunCanRequestTeppRetry(run: AnalysisRun): boolean { return run.run_kind_code === "analysis_run_tepp" && run.status_code === "analysis_status_failed"; } @@ -1999,6 +2001,8 @@ function AnalysisRunsPanel({ currentReportPeriod, onSelectPost, onSelectReportPeriod, + corporateEntities, + entitiesLoadError, }: { accessToken: string; currentReportPeriod?: string; @@ -2009,12 +2013,24 @@ function AnalysisRunsPanel({ groupingKey?: string, groupingLabel?: 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 [starting, setStarting] = 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) @@ -2022,19 +2038,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); } @@ -2056,24 +2102,6 @@ function AnalysisRunsPanel({ } } - async function handleRequestTepp() { - setError(null); - setRequesting(true); - try { - const created = await createAnalysisRun(accessToken, { - run_kind_code: "analysis_run_tepp", - idempotency_key: crypto.randomUUID(), - }); - const listed = await fetchAnalysisRuns(accessToken); - setRuns(listed.analysis_runs); - setSelected(created); - } catch (err) { - setError(err instanceof BackendError ? err.message : String(err)); - } finally { - setRequesting(false); - } - } - async function handleOpen(runId: string) { setError(null); try { @@ -2098,16 +2126,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 @@ -2170,14 +2208,10 @@ function AnalysisRunsPanel({ )} {analysisRunCanRequestTeppRetry(selected) && ( - +

+ Connect a TEPP transport from this Failed row. Request a lineage + reconstruction does not invent a measurement. +

)} {analysisRunReportPeriod(selected) && onSelectReportPeriod && (