From 27f925a3433cb138ab1e0216c6aa781ea74ed8da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 10:38:10 +0900 Subject: [PATCH 001/161] feat: R&R's named actor is a PROV-O Agent, not always a person (0.68.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed against real Milestone 2 SAP CRM VOC data: post_summary.py's R&R extraction forced every named actor into a person slot, but real business correspondence routinely names an organization acting in its own name ("당사," "SEWA," "Siemens," "GECO"), not an individual. - RoleResponsibility.actor_name (renamed from person_name) gains actor_type_code (prov_person/prov_organization, W3C PROV-O grounded: Lebo, Sahoo, & McGuinness, 2013) and an LLM-inferred affiliated_organization_name for person actors -- a bare name without an employer is hard to place. - Ontology: :RoleActorPerson rdfs:subClassOf prov:Person, :RoleActorOrganization rdfs:subClassOf prov:Organization -- genuine subclasses of the real external PROV-O classes, distinct from the ontology's existing :Person (a cataloged Keyman with a stable person_id; an R&R actor is a free-text name with no cataloged identity). - migrations/0012_role_responsibility_agent_type.sql renames the column via RENAME COLUMN (preserves existing rows), not a drop/recreate. - Popup R&R list shows a Person/Organization badge and the inferred affiliation; only a person actor still links to the Keyman panel. - Also fixes a real deployment gap found via browser E2E testing: migrations 0005-0011 had accumulated on main without ever being applied to the long-running demo Postgres volume, surfacing as CORS-looking failures (missing-table 500s lose their CORS header) on Evaluate, Reports, Summary, and Chat. ADR 0006. Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE.md | 28 +++++ CHANGELOG.md | 23 ++++ backend/app/post_summary_ingestion.py | 45 +++++-- backend/tests/test_api.py | 23 ++-- ...0006-role-responsibility-agent-ontology.md | 113 ++++++++++++++++++ docs/ontology/lineageweave-kg.ttl | 42 ++++++- frontend/package.json | 2 +- frontend/src/App.css | 23 ++++ frontend/src/App.test.tsx | 14 ++- frontend/src/App.tsx | 17 ++- frontend/src/api.ts | 4 +- lineageweave/__init__.py | 2 +- lineageweave/post_summary.py | 83 +++++++++++-- migrations/0001_initial_schema.sql | 13 +- .../0012_role_responsibility_agent_type.sql | 31 +++++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 13 +- tests/test_ontology.py | 45 ++++++- tests/test_post_summary.py | 44 ++++++- 19 files changed, 509 insertions(+), 58 deletions(-) create mode 100644 docs/adr/0006-role-responsibility-agent-ontology.md create mode 100644 migrations/0012_role_responsibility_agent_type.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 617b8b95..754ebed5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -682,3 +682,31 @@ checks a well-known public foundation name ("Mozilla Foundation") against a deliberately fabricated one in the same request, asserting the former comes back `verify_corroborated` with a real evidence URL and the latter `verify_uncorroborated` with none. + +## Phase 7: R&R's named actor is a PROV-O Agent, not always a person + +Confirmed against real Milestone 2 SAP CRM VOC data, not a hypothetical: +`post_summary.py`'s R&R extraction forced every named actor into a +person slot, but real business correspondence routinely names an +organization acting in its own name ("당사" [our company], "SEWA," +"Siemens," "GECO"), not an individual. See +[ADR 0006](docs/adr/0006-role-responsibility-agent-ontology.md). + +Grounded in W3C PROV-O (Lebo, Sahoo, & McGuinness, 2013): +`RoleResponsibility` (renamed field `actor_name`, was `person_name` -- +the field can hold an organization's name now, so "person" in the name +would be wrong) gains `actor_type_code` (`prov_person` / +`prov_organization`, defaulting to person when the model omits it) and +`affiliated_organization_name` (an LLM-inferred affiliation for a +person actor, since a bare name without an employer is hard to place). +The ontology gains `:RoleActorPerson rdfs:subClassOf prov:Person` and +`:RoleActorOrganization rdfs:subClassOf prov:Organization` -- genuine +subclasses of the real external PROV-O classes (imported via the +`prov:` namespace), kept distinct from the ontology's existing `:Person` +(node_type's cataloged Keyman with a stable `person_id`) since an R&R +actor is a free-text name with no cataloged identity of its own. +`migrations/0012_role_responsibility_agent_type.sql` renames the +`post_summary_role` column via `RENAME COLUMN` (preserves existing +rows) rather than a drop/recreate. The popup's R&R list shows a +Person/Organization badge and the inferred affiliation; only a person +actor still links to the Keyman panel. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f014d05..48b79dfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ 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.68.0] - 2026-08-14 + +### Changed + +- R&R's named actor is no longer forced into a person slot. A real + business post can name an organization acting in its own name + ("당사," "SEWA," "Siemens," "GECO"), not an individual -- + `RoleResponsibility.actor_name` (renamed from `person_name`) now + carries `actor_type_code` (Person / Organization, W3C PROV-O + grounded) and an LLM-inferred `affiliated_organization_name` for + person actors. The popup's R&R list shows a Person/Organization + badge and the inferred affiliation; only a person actor still links + to the Keyman panel. See ADR 0006. + +### Fixed + +- Applied migrations 0005-0011 (post evaluation, period reports, FIPC + linking, shared metric bank, report item information, persisted + chat) to the long-running demo database -- these had accumulated on + `main` without ever being applied to the running demo Postgres + volume, surfacing as CORS-looking failures (missing-table 500s + without CORS headers) on Evaluate, Reports, Summary, and Chat. + ## [0.67.0] - 2026-08-14 ### Added diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 7e7569d6..366195a3 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -7,6 +7,7 @@ import asyncpg from lineageweave.fixtures import fixture_thread_cast +from lineageweave.ontology import ontology_annotations from lineageweave.post_summary import PostSummary, RoleResponsibility @@ -23,8 +24,8 @@ async def fetch_persisted_summary(conn: asyncpg.Connection, post_id: str) -> dic post_id, ) roles = await conn.fetch( - "select person_name, responsibility from post_summary_role " - "where post_id = $1 order by person_name", + "select actor_name, responsibility, actor_type_code, affiliated_organization_name " + "from post_summary_role where post_id = $1 order by actor_name", post_id, ) return { @@ -32,7 +33,13 @@ async def fetch_persisted_summary(conn: asyncpg.Connection, post_id: str) -> dic "korean_summary": header["korean_summary"], "key_events": [row["event_text"] for row in events], "roles_and_responsibilities": [ - {"person_name": row["person_name"], "responsibility": row["responsibility"]} + { + "actor_name": row["actor_name"], + "responsibility": row["responsibility"], + "actor_type_code": row["actor_type_code"], + "affiliated_organization_name": row["affiliated_organization_name"], + **ontology_annotations(row["actor_type_code"]), + } for row in roles ], } @@ -55,10 +62,14 @@ async def persist_post_summary(conn: asyncpg.Connection, post_id: str, summary: ) for role in summary.roles_and_responsibilities: await conn.execute( - "insert into post_summary_role (post_id, person_name, responsibility) values ($1, $2, $3)", + "insert into post_summary_role " + "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name) " + "values ($1, $2, $3, $4, $5)", post_id, - role.person_name, + role.actor_name, role.responsibility, + role.actor_type_code, + role.affiliated_organization_name, ) payload = await fetch_persisted_summary(conn, post_id) if payload is None: @@ -75,8 +86,16 @@ def seeded_demo_summary() -> PostSummary: ), key_events=("출하 지연 후속 연락",), roles_and_responsibilities=( - RoleResponsibility(person_name="Ada West", responsibility="일정 확인 후속"), - RoleResponsibility(person_name="Priya Nair", responsibility="고객 측 수신"), + RoleResponsibility( + actor_name="Ada West", + responsibility="일정 확인 후속", + affiliated_organization_name="Demo Corp", + ), + RoleResponsibility( + actor_name="Priya Nair", + responsibility="고객 측 수신", + affiliated_organization_name="Northridge Grid", + ), ), ) @@ -108,7 +127,11 @@ def _roles_for_fixture(post_title: str) -> tuple[RoleResponsibility, ...]: if cast is None or not cast.person_names: return () return tuple( - RoleResponsibility(person_name=name, responsibility=responsibility) + RoleResponsibility( + actor_name=name, + responsibility=responsibility, + affiliated_organization_name=_FIXTURE_ROLE_AFFILIATION.get(name), + ) for name in cast.person_names if (responsibility := _FIXTURE_ROLE_RESPONSIBILITY.get(name)) ) @@ -120,6 +143,12 @@ def _roles_for_fixture(post_title: str) -> tuple[RoleResponsibility, ...]: "Jordan Hale": "사양 검토", } +_FIXTURE_ROLE_AFFILIATION = { + "Ada West": "Demo Corp", + "Priya Nair": "Northridge Grid", + "Jordan Hale": "Westfield Power", +} + def _summary(korean: str, *events: str) -> PostSummary: return PostSummary(korean_summary=korean, key_events=events) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 74ab4470..0d3639b1 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -142,7 +142,9 @@ def seeded_db(demo_analyst_token): "('relation_verification_status', 'verify_uncorroborated', 'No corroborating evidence found'), " "('evaluation_criterion', 'general_sentiment_positive', 'Constructive stance'), " "('evaluation_criterion', 'general_sentiment_negative', 'Negative stance'), " - "('evaluation_criterion', 'sales_lead_specificity', 'Sales-lead specificity')" + "('evaluation_criterion', 'sales_lead_specificity', 'Sales-lead specificity'), " + "('prov_agent_type', 'prov_person', 'Person'), " + "('prov_agent_type', 'prov_organization', 'Organization')" ) cur.execute( "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " @@ -363,8 +365,9 @@ def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token (seeded_db["public_post_id"],), ) cur.execute( - "insert into post_summary_role (post_id, person_name, responsibility) " - "values (%s, 'Ada West', '후속 연락')", + "insert into post_summary_role " + "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name) " + "values (%s, 'Ada West', '후속 연락', 'prov_person', 'Demo Corp')", (seeded_db["public_post_id"],), ) finally: @@ -378,9 +381,13 @@ def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token body = response.json() assert body["korean_summary"] == "저장된 한국어 요약입니다." assert body["key_events"] == ["저장된 이벤트"] - assert body["roles_and_responsibilities"] == [ - {"person_name": "Ada West", "responsibility": "후속 연락"} - ] + assert len(body["roles_and_responsibilities"]) == 1 + role = body["roles_and_responsibilities"][0] + assert role["actor_name"] == "Ada West" + assert role["responsibility"] == "후속 연락" + assert role["actor_type_code"] == "prov_person" + assert role["affiliated_organization_name"] == "Demo Corp" + assert role["ontology_label"] == "Role actor (person)" def test_seed_demo_summary_surfaces_on_get_summary(client, demo_analyst_token, seeded_db) -> None: @@ -407,7 +414,7 @@ def test_seed_demo_summary_surfaces_on_get_summary(client, demo_analyst_token, s body = response.json() assert "에이다" in body["korean_summary"] assert body["key_events"] - assert any(role["person_name"] == "Ada West" for role in body["roles_and_responsibilities"]) + assert any(role["actor_name"] == "Ada West" for role in body["roles_and_responsibilities"]) def test_seed_fixture_summaries_surface_on_get_summary(client, demo_analyst_token, seeded_db) -> None: @@ -466,7 +473,7 @@ def test_seed_fixture_summaries_surface_on_get_summary(client, demo_analyst_toke assert fork.status_code == 200, fork.text assert "재협상" in fork.json()["korean_summary"] assert fork.json()["key_events"] - fork_roles = {role["person_name"] for role in fork.json()["roles_and_responsibilities"]} + fork_roles = {role["actor_name"] for role in fork.json()["roles_and_responsibilities"]} assert fork_roles == {"Ada West", "Priya Nair"} calendar = client.get( diff --git a/docs/adr/0006-role-responsibility-agent-ontology.md b/docs/adr/0006-role-responsibility-agent-ontology.md new file mode 100644 index 00000000..45b8008e --- /dev/null +++ b/docs/adr/0006-role-responsibility-agent-ontology.md @@ -0,0 +1,113 @@ +# ADR 0006 — R&R's named actor is a PROV-O Agent, not always a person + +**Decision status:** Accepted +**Date:** 2026-08-14 + +## Context + +The product brief flags a real gap in `post_summary.py`'s R&R (roles & +responsibilities) extraction, confirmed against real SAP CRM VOC records +during Milestone 2 analysis: the acting party a post's text names is not +always a person. Real business correspondence routinely names an +organization acting in its own name -- "당사" (our company), "SEWA," +"Siemens," "GECO" -- not a named individual. The brief's own wording: +"주체가 사람이 아니라 기관 ... 으로 나타나는 경우도 있으므로 일반적인 +표준 Ontology로 조치할 것" (the acting subject sometimes appears as an +organization rather than a person, so handle it with a general standard +Ontology), plus "사람만 넣어서는 소속 기관을 이해하기 어려우므로 소속 +기관 추론까지 포함시킬 것" (a bare person name is hard to place without +their organization, so infer the affiliation too). + +Before this change, `RoleResponsibility.person_name` had no way to +express "this actor is an organization" -- every entry was forced into +a person slot, and an organization actor's name would sit +indistinguishable from an unresolved person. + +## Decision + +Ground the distinction in W3C PROV-O (Lebo, Sahoo, & McGuinness, 2013): +`prov:Agent` is the general acting-party class, with `prov:Person` and +`prov:Organization` as its two recognized subclasses -- an existing, +widely-adopted standard for exactly this "who/what acted" provenance +question, not a bespoke local invention. + +`RoleResponsibility` (`lineageweave/post_summary.py`) gains: +- `actor_name` (renamed from `person_name` -- the field can now hold an + organization's name too, so "person" in the field name would be + actively wrong). +- `actor_type_code`: `prov_person` / `prov_organization` + (`common_lookup_value` category `prov_agent_type`), defaulting to + `prov_person` when the LLM's response omits the field, matching this + repo's existing degrade-gracefully-not-fail discipline. +- `affiliated_organization_name`: for a person actor, the organization + the text names or clearly implies they work for, inferred by the same + LLM call rather than left for a human to cross-reference against the + Keyman panel separately. `None` when the text gives nothing to infer, + or when the actor is itself an organization (its own name already + answers "which organization"). + +The LLM prompt now explicitly instructs the model to decide +person-vs-organization per actor rather than defaulting every named +actor to a person, and to give an affiliation when the text supports +one. + +Ontology (`docs/ontology/lineageweave-kg.ttl`, extending +[ADR 0004](0004-knowledge-graph-ontology.md)'s vocabulary): +`:RoleActorPerson rdfs:subClassOf prov:Person` and +`:RoleActorOrganization rdfs:subClassOf prov:Organization`, each +carrying the `:lookupCode` annotation linking it to the matching +`common_lookup_value` row -- these are genuinely subclasses of the real +external PROV-O classes (imported via the `prov:` prefix), not +same-named local terms that merely resemble the standard. Kept distinct +from the ontology's existing `:Person` (node_type's `node_person`, +i.e. a cataloged Keyman with a stable `person_id`): an R&R actor is a +free-text name with no cataloged identity of its own, and may not even +resolve to a Keyman row. + +Persistence: `post_summary_role` gains `actor_type_code` (FK to +`common_lookup_value`, default `prov_person`) and +`affiliated_organization_name`; `person_name` is renamed to +`actor_name` via `migrations/0012_role_responsibility_agent_type.sql`'s +`ALTER TABLE ... RENAME COLUMN` (preserves every existing row's data, +unlike a drop/recreate) plus the two new `ADD COLUMN IF NOT EXISTS` +statements, with `migrations/0001_initial_schema.sql` updated directly +for a fresh install, matching this repo's established pattern (e.g. +ADR 0005's `verification_status_code` additions). + +UI: the popup's R&R list (`frontend/src/App.tsx`) shows a +Person/Organization badge per actor and the inferred affiliation in +parentheses; only a person actor is still linked to the Keyman panel +(an organization actor has no `person_id` to link to). + +## Consequences + +- `RoleResponsibility.person_name` is a breaking rename to `actor_name` + across the JSON wire contract (`GET /api/posts/{id}/summary`), the + DB column, and every call site. Accepted because the field's old name + was actively misleading once an organization actor is a real, + intended value, not a hypothetical edge case -- confirmed against + real Milestone 2 SAP CRM VOC data. +- `prov_agent_type` is a `common_lookup_value` category seeded by its + own migration file (0012), not literally embedded in + `scripts/seed_demo_data.py`'s SQL string the way ADR 0004's original + five covered categories are -- `tests/test_ontology.py`'s round-trip + check reads 0012's file content alongside the seed script's own text + so this still closes the loop, rather than being silently excluded + the way `evaluation_criterion` / `relation_verification_status` + currently are. +- The affiliation inference is opportunistic, not authoritative: it is + a same-request LLM guess from the post's own text, not resolved + against `corporate_entity` the way Keyman affiliations are (see + `lineageweave/corporate_hierarchy_resolution.py`). A future slice + could route it through the same resolver if real usage shows the + free-text name needs matching back to a cataloged organization. + +## Related + +Extends [ADR 0004](0004-knowledge-graph-ontology.md)'s Ontology/ +Semantic-Layer vocabulary and reuses its round-trip enforcement +mechanism (`tests/test_ontology.py`). + +## References (APA 7th) + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/ diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index c892d609..88436816 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -4,23 +4,26 @@ @prefix rdfs: . @prefix skos: . @prefix xsd: . +@prefix prov: . ################################################################# # LineageWeave Knowledge Graph Ontology # # The formal OWL 2 / RDFS / SKOS vocabulary for the -# `knowledge_graph_edge` table's node/edge types and the +# `knowledge_graph_edge` table's node/edge types, the # `entity_relationship_type` / `person_side` / `corporate_entity_level` -# controlled vocabularies in migrations/0001_initial_schema.sql. +# controlled vocabularies in migrations/0001_initial_schema.sql, and +# `post_summary_role.actor_type_code` (migrations/0012). # # `knowledge_graph_edge` (source_node_type_code, source_node_id) -- # [edge_type_code] --> (target_node_type_code, target_node_id) is # already an RDF triple in shape (Cyganiak, Wood, & Lanthaler, 2014); # this file is the formal semantic layer over it -- PostgreSQL stays # the source of record. See docs/adr/0004-knowledge-graph-ontology.md -# for the full design rationale, and tests/test_ontology.py for the -# round-trip check that every code below actually exists as a -# common_lookup_value row, and vice versa. +# for the KG design rationale, docs/adr/0006-role-responsibility-agent-ontology.md +# for the R&R actor-type rationale (grounded in W3C PROV-O), and +# tests/test_ontology.py for the round-trip check that every code below +# actually exists as a common_lookup_value row, and vice versa. # # Every custom term carries a :lookupCode annotation naming the exact # `common_lookup_value.lookup_code` it corresponds to -- that literal @@ -29,7 +32,7 @@ a owl:Ontology ; rdfs:label "LineageWeave Knowledge Graph Ontology" ; - rdfs:comment "Formal OWL 2 / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, and corporate_entity_level controlled vocabularies." . + rdfs:comment "Formal OWL 2 / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, corporate_entity_level, and post_summary_role.actor_type_code controlled vocabularies." . :lookupCode a owl:AnnotationProperty ; rdfs:label "lookup code" ; @@ -152,3 +155,30 @@ :GroupLevel skos:narrower :CompanyLevel . :CompanyLevel skos:narrower :PlantLevel . + +################################################################# +# Classes -- prov_agent_type (post_summary_role.actor_type_code) +# +# A post's R&R (roles & responsibilities) actor is not always a person +# -- real business correspondence routinely names an organization +# acting in its own name ("당사" [our company], "SEWA," "Siemens," +# "GECO"). Grounded directly in W3C PROV-O (Lebo, Sahoo, & McGuinness, +# 2013): prov:Agent is the general acting-party class, with prov:Person +# and prov:Organization its two recognized subclasses. These are +# distinct from :Person / :OurSidePerson / :CounterpartyPerson above: +# node_type's :Person is a cataloged_person row with a stable person_id +# a Keyman panel links to; an R&R actor is a free-text name with no +# cataloged identity of its own (it may not even resolve to a Keyman). +################################################################# + +:RoleActorPerson a owl:Class ; + rdfs:subClassOf prov:Person ; + rdfs:label "Role actor (person)" ; + rdfs:comment "An R&R actor that is a named individual, per prov:Person." ; + :lookupCode "prov_person" . + +:RoleActorOrganization a owl:Class ; + rdfs:subClassOf prov:Organization ; + rdfs:label "Role actor (organization)" ; + rdfs:comment "An R&R actor that is an organization acting in its own name, per prov:Organization." ; + :lookupCode "prov_organization" . diff --git a/frontend/package.json b/frontend/package.json index c121006c..0e8e1c3e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.67.0", + "version": "0.68.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index c6909925..177fcf7c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -245,6 +245,29 @@ border-radius: 1rem; } +.actor-type-badge { + font-size: 0.7rem; + padding: 0.05rem 0.4rem; + border-radius: 0.3rem; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.actor-type-prov_person { + background: #e8eaf6; + color: #303f9f; +} + +.actor-type-prov_organization { + background: #fff3e0; + color: #e65100; +} + +.rr-affiliation { + opacity: 0.7; + font-size: 0.9rem; +} + .verification-verify_pending { background: #e0e0e0; color: #444; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f2d9ee8b..747f4220 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -453,8 +453,18 @@ describe("App, authenticated", () => { korean_summary: "이것은 요약입니다.", key_events: ["첫 번째 이벤트"], roles_and_responsibilities: [ - { person_name: "Ada West", responsibility: "우리 측 후속" }, - { person_name: "Priya Nair", responsibility: "고객 측 수신" }, + { + actor_name: "Ada West", + responsibility: "우리 측 후속", + actor_type_code: "prov_person", + affiliated_organization_name: "Demo Corp", + }, + { + actor_name: "Priya Nair", + responsibility: "고객 측 수신", + actor_type_code: "prov_person", + affiliated_organization_name: "Northridge Grid", + }, ], }), ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3ef38852..be899929 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1176,13 +1176,19 @@ function PostDetailPopup({

R&R

    {summary.roles_and_responsibilities.map((rr, i) => { - const person = keymen?.find((row) => row.person_name === rr.person_name); + const isPerson = rr.actor_type_code === "prov_person"; + const person = isPerson + ? keymen?.find((row) => row.person_name === rr.actor_name) + : undefined; return (
  • + + {isPerson ? "Person" : "Organization"} + {" "} {person ? ( ) : ( - {rr.person_name} + {rr.actor_name} + )} + {rr.affiliated_organization_name && ( + ({rr.affiliated_organization_name}) )} : {rr.responsibility}
  • diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2b669277..03d7a71e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -83,8 +83,10 @@ export interface RelatedNode { } export interface PostRoleResponsibility { - person_name: string; + actor_name: string; responsibility: string; + actor_type_code: string; + affiliated_organization_name: string | null; } export interface PostAiSummary { diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index a4119bf0..90bd896b 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.67.0" +__version__ = "0.68.0" diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py index 4974268c..e229081c 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -14,7 +14,20 @@ bullet, not a summary sentence. - **R&R (roles & responsibilities)**: semantic role labeling (Gildea & Jurafsky, 2002) -- who did what, framed as an agent/action/responsibility - triple per person named in the post, not prose. + triple per named actor in the post, not prose. The actor is not always + a person: real business correspondence routinely names an organization + as the acting party ("당사" [our company], "SEWA," "Siemens," "GECO"), + not an individual. Modeling every actor as a person loses this + distinction and makes an organization's affiliation-less name look + like an unresolved person. Grounded in W3C PROV-O (Lebo, Sahoo, & + McGuinness, 2013): ``prov:Agent`` is the general acting-party class, + with ``prov:Person`` and ``prov:Organization`` as its two recognized + subclasses -- the same distinction ``keyman_extraction``'s two-sided + (our-side/counterparty) person model already keeps for *people*, one + level up. A person actor also gets an inferred + ``affiliated_organization_name`` where the text supports it: a bare + person name without who they work for is hard to place in the same + way an unresolved organization name is. Same pluggable-client, never-fake-a-missing-channel discipline as every other Phase 2/3 channel: :class:`NullPostSummaryClient` makes the channel @@ -30,13 +43,35 @@ from .http_client import post_json +# common_lookup_value category "prov_agent_type" -- PROV-O's prov:Person / +# prov:Organization, the two subclasses of prov:Agent this repo models. +ACTOR_TYPE_PERSON = "prov_person" +ACTOR_TYPE_ORGANIZATION = "prov_organization" +_VALID_ACTOR_TYPE_CODES = frozenset({ACTOR_TYPE_PERSON, ACTOR_TYPE_ORGANIZATION}) + @dataclass(frozen=True) class RoleResponsibility: - """One person's role/responsibility as derived from the post text.""" + """One actor's role/responsibility as derived from the post text. + + Attributes: + actor_name: the person's or organization's name as named in the + text. + responsibility: what they are responsible for or did. + actor_type_code: ``ACTOR_TYPE_PERSON`` or ``ACTOR_TYPE_ORGANIZATION`` + (PROV-O ``prov:Person`` / ``prov:Organization``) -- which this + actor actually is, not assumed to be a person. + affiliated_organization_name: for a person actor, the + organization the text says or implies they work for, when + the text supports it; ``None`` when the text gives no + affiliation to infer, or for an organization actor (its own + name already answers "which organization"). + """ - person_name: str + actor_name: str responsibility: str + actor_type_code: str = ACTOR_TYPE_PERSON + affiliated_organization_name: str | None = None @dataclass(frozen=True) @@ -81,16 +116,27 @@ def summarize(self, post_title: str, post_body: str) -> PostSummary: 2. A list of key events: discrete, datable occurrences mentioned in the post (e.g. "a bid was submitted", "a delivery date was confirmed"), each as a short phrase. -3. A list of roles & responsibilities: for each named person in the post, - one short phrase describing what they are responsible for or did, - according to the text. +3. A list of roles & responsibilities: for each named actor in the post + -- a person OR an organization acting in its own name (e.g. "당사" + [our company], "SEWA," "Siemens," "GECO") -- one short phrase + describing what they are responsible for or did, according to the + text. Do not force an organization's name into a person slot: decide + whether each actor is a person or an organization, and say which. + When the actor is a person and the text names or clearly implies who + they work for, also give that organization's name -- a bare person + name without their employer is hard to place. Reply with ONLY a JSON object (no markdown fences, no prose) with exactly these fields: "korean_summary": string "key_events": array of strings - "roles_and_responsibilities": array of objects, each with - "person_name" and "responsibility" string fields + "roles_and_responsibilities": array of objects, each with: + "actor_name": string + "responsibility": string + "actor_type": exactly "person" or "organization" + "affiliated_organization_name": string, or null when the actor is an + organization, or when the actor is a person and the text gives no + affiliation to infer Post title: {title} Post body: {body} @@ -134,15 +180,32 @@ def parse_summary_response(content: str) -> PostSummary | None: for entry in rr_raw: if not isinstance(entry, dict): continue - name = entry.get("person_name") + name = entry.get("actor_name") responsibility = entry.get("responsibility") + actor_type_raw = entry.get("actor_type") + actor_type_code = ( + ACTOR_TYPE_ORGANIZATION if actor_type_raw == "organization" else ACTOR_TYPE_PERSON + ) + affiliation_raw = entry.get("affiliated_organization_name") + affiliated_organization_name = ( + affiliation_raw.strip() + if isinstance(affiliation_raw, str) and affiliation_raw.strip() + else None + ) if ( isinstance(name, str) and name.strip() and isinstance(responsibility, str) and responsibility.strip() ): - roles.append(RoleResponsibility(person_name=name.strip(), responsibility=responsibility.strip())) + roles.append( + RoleResponsibility( + actor_name=name.strip(), + responsibility=responsibility.strip(), + actor_type_code=actor_type_code, + affiliated_organization_name=affiliated_organization_name, + ) + ) return PostSummary( korean_summary=korean_summary.strip(), diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 6a2676a4..c7391219 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -43,7 +43,8 @@ comment on table common_lookup_value is 'Every ENUM-like value in this schema (voc_type, post_visibility, ' 'entity_relationship_type, person_side, edge_type, node_type, ' 'ticket_status, permission, corporate_entity_level, ' - 'relation_verification_status, evaluation_criterion) lives here once. ' + 'relation_verification_status, evaluation_criterion, prov_agent_type) ' + 'lives here once. ' 'lookup_code is unique across all categories -- see the unique(lookup_code) comment.'; -- --------------------------------------------------------------------- @@ -211,11 +212,17 @@ create table post_summary_event ( primary key (post_id, event_ordinal) ); +-- actor_type_code: R&R Ontology, see migrations/0012_role_responsibility_agent_type.sql +-- and ADR 0006 -- a named actor is not always a person (an organization +-- can act in its own name, e.g. "당사," "SEWA"), so this is not folded +-- into person_name's own meaning. create table post_summary_role ( post_id uuid not null references post_summary_result (post_id) on delete cascade, - person_name text not null, + actor_name text not null, responsibility text not null, - primary key (post_id, person_name) + actor_type_code text not null default 'prov_person' references common_lookup_value (lookup_code), + affiliated_organization_name text, + primary key (post_id, actor_name) ); -- Persisted in-popup Q&A. Seed writes a synthetic exchange so diff --git a/migrations/0012_role_responsibility_agent_type.sql b/migrations/0012_role_responsibility_agent_type.sql new file mode 100644 index 00000000..30b483b0 --- /dev/null +++ b/migrations/0012_role_responsibility_agent_type.sql @@ -0,0 +1,31 @@ +-- Roles & responsibilities' named actor is not always a person -- real +-- business correspondence routinely names an organization acting in its +-- own name ("당사" [our company], "SEWA," "Siemens," "GECO"), not an +-- individual. Adds a PROV-O-grounded person/organization distinction +-- (see ADR 0006) plus an inferred affiliated-organization name for +-- person actors. The rename below (person_name -> actor_name) preserves +-- every existing row's data -- a plain RENAME COLUMN, not a drop/recreate +-- -- since a volume that already ran the pre-0006 0001 has real rows +-- under the old name. + +insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values + ('prov_agent_type', 'prov_person', 'Person', 0), + ('prov_agent_type', 'prov_organization', 'Organization', 1) +on conflict (lookup_code) do nothing; + +do $$ +begin + if exists ( + select 1 from information_schema.columns + where table_name = 'post_summary_role' and column_name = 'person_name' + ) then + alter table post_summary_role rename column person_name to actor_name; + end if; +end $$; + +alter table post_summary_role + add column if not exists actor_type_code text not null default 'prov_person' + references common_lookup_value (lookup_code); + +alter table post_summary_role + add column if not exists affiliated_organization_name text; diff --git a/pyproject.toml b/pyproject.toml index d321fcaa..1b0e694a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.67.0" +version = "0.68.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 72318f33..59e487d1 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -106,6 +106,7 @@ def seed( cur.execute((migrations / "0009_shared_metric_bank.sql").read_text()) cur.execute((migrations / "0010_report_item_information.sql").read_text()) cur.execute((migrations / "0011_post_chat_result.sql").read_text()) + cur.execute((migrations / "0012_role_responsibility_agent_type.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values @@ -404,8 +405,16 @@ def _write_post_summary(cur, post_id, summary) -> None: ) for role in summary.roles_and_responsibilities: cur.execute( - "insert into post_summary_role (post_id, person_name, responsibility) values (%s, %s, %s)", - (post_id, role.person_name, role.responsibility), + "insert into post_summary_role " + "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name) " + "values (%s, %s, %s, %s, %s)", + ( + post_id, + role.actor_name, + role.responsibility, + role.actor_type_code, + role.affiliated_organization_name, + ), ) diff --git a/tests/test_ontology.py b/tests/test_ontology.py index 41423ada..0a85d736 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -31,12 +31,27 @@ _SEED_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "seed_demo_data.py" +# 0012 seeds prov_agent_type via its own migration SQL (ADR 0006), not +# literally embedded in seed_demo_data.py's own source text the way the +# other covered categories are -- read alongside it below so the +# round-trip still sees those two codes. +_PROV_AGENT_TYPE_MIGRATION_PATH = ( + Path(__file__).resolve().parents[1] / "migrations" / "0012_role_responsibility_agent_type.sql" +) + # The categories this ontology covers (ADR 0004's scope). seed_demo_data.py # also seeds categories this ontology deliberately does not model yet # (post_visibility, voc_type, permission, ticket_status) -- those are # real, expected gaps, not a test bug. _ONTOLOGY_COVERED_CATEGORIES = frozenset( - {"node_type", "edge_type", "entity_relationship_type", "person_side", "corporate_entity_level"} + { + "node_type", + "edge_type", + "entity_relationship_type", + "person_side", + "corporate_entity_level", + "prov_agent_type", + } ) _INSERT_TUPLE_PATTERN = re.compile(r"\('([a-z_]+)',\s*'([a-z_]+)'") @@ -44,12 +59,12 @@ def _seeded_lookup_codes_for_covered_categories() -> set[str]: """Every `(lookup_category, lookup_code)` pair seed_demo_data.py's own - SQL literally inserts, filtered to the categories this ontology - covers. Parsed from source, not executed -- this is a static - consistency check between two committed files, not a live-database - test. + SQL, plus 0012's migration SQL, literally inserts, filtered to the + categories this ontology covers. Parsed from source, not executed -- + this is a static consistency check between committed files, not a + live-database test. """ - source = _SEED_SCRIPT_PATH.read_text() + source = _SEED_SCRIPT_PATH.read_text() + _PROV_AGENT_TYPE_MIGRATION_PATH.read_text() return { code for category, code in _INSERT_TUPLE_PATTERN.findall(source) @@ -129,6 +144,24 @@ def test_mentions_property_domain_and_range_match_the_schema() -> None: assert (LW.mentions, RDFS.range, LW.Person) in graph +def test_prov_agent_type_terms_resolve_and_subclass_real_prov_o() -> None: + """Beyond the generic round-trip above: the two prov_agent_type terms + must actually subclass the real external W3C PROV-O classes, not + just carry a matching :lookupCode -- the whole point of grounding + this in a standard ontology is that :RoleActorPerson really is a + prov:Person, not a same-named local invention. + """ + from rdflib import URIRef + from rdflib.namespace import Namespace + + prov = Namespace("http://www.w3.org/ns/prov#") + graph = load_ontology() + assert iri_for_lookup_code("prov_person") == str(LW.RoleActorPerson) + assert iri_for_lookup_code("prov_organization") == str(LW.RoleActorOrganization) + assert (LW.RoleActorPerson, RDFS.subClassOf, URIRef(prov.Person)) in graph + assert (LW.RoleActorOrganization, RDFS.subClassOf, URIRef(prov.Organization)) in graph + + def test_corporate_entity_level_hierarchy_is_broadest_first() -> None: """Group is broader than Company is broader than Plant -- the Acme Group -> Acme Electronics Korea -> plant direction the diff --git a/tests/test_post_summary.py b/tests/test_post_summary.py index 52694bc3..7812df03 100644 --- a/tests/test_post_summary.py +++ b/tests/test_post_summary.py @@ -39,13 +39,47 @@ def test_parses_a_well_formed_json_object() -> None: content = ( '{"korean_summary": "회의 후속 조치에 대한 요약입니다.", ' '"key_events": ["입찰 워크숍 진행", "검사 일정 확인 요청"], ' - '"roles_and_responsibilities": [{"person_name": "Jordan Hale", "responsibility": "입찰 일정 안내"}]}' + '"roles_and_responsibilities": [{"actor_name": "Jordan Hale", "responsibility": "입찰 일정 안내", ' + '"actor_type": "person", "affiliated_organization_name": "Westfield Power"}]}' ) summary = parse_summary_response(content) assert summary is not None assert summary.korean_summary == "회의 후속 조치에 대한 요약입니다." assert summary.key_events == ("입찰 워크숍 진행", "검사 일정 확인 요청") - assert summary.roles_and_responsibilities[0].person_name == "Jordan Hale" + role = summary.roles_and_responsibilities[0] + assert role.actor_name == "Jordan Hale" + assert role.actor_type_code == "prov_person" + assert role.affiliated_organization_name == "Westfield Power" + + +def test_organization_actor_is_not_forced_into_a_person_slot() -> None: + """A named actor that is genuinely an organization (e.g. our own + company acting in its own name, not a named individual) must parse + as ``prov_organization``, not silently default to person -- the + default only applies when the model omits ``actor_type`` entirely. + """ + content = ( + '{"korean_summary": "당사가 요청 사항을 확인했습니다.", "key_events": [], ' + '"roles_and_responsibilities": [{"actor_name": "당사", "responsibility": "요청 확인", ' + '"actor_type": "organization", "affiliated_organization_name": null}]}' + ) + summary = parse_summary_response(content) + assert summary is not None + role = summary.roles_and_responsibilities[0] + assert role.actor_name == "당사" + assert role.actor_type_code == "prov_organization" + assert role.affiliated_organization_name is None + + +def test_missing_actor_type_defaults_to_person() -> None: + content = ( + '{"korean_summary": "요약", "key_events": [], ' + '"roles_and_responsibilities": [{"actor_name": "Ada West", "responsibility": "후속"}]}' + ) + summary = parse_summary_response(content) + assert summary is not None + assert summary.roles_and_responsibilities[0].actor_type_code == "prov_person" + assert summary.roles_and_responsibilities[0].affiliated_organization_name is None def test_missing_korean_summary_returns_none() -> None: @@ -75,7 +109,7 @@ def test_every_sample_record_has_a_seeded_korean_summary() -> None: assert summary.korean_summary not in seen seen.add(summary.korean_summary) cast = fixture_thread_cast(rec.label) - names = {role.person_name for role in summary.roles_and_responsibilities} + names = {role.actor_name for role in summary.roles_and_responsibilities} if cast is not None and cast.person_names: assert set(cast.person_names) <= names else: @@ -91,7 +125,7 @@ def test_every_sample_record_has_a_seeded_korean_summary() -> None: def test_malformed_roles_entries_are_skipped_not_crashed_on() -> None: content = ( '{"korean_summary": "요약", "key_events": [], ' - '"roles_and_responsibilities": [{"person_name": "Only Name"}, "not an object"]}' + '"roles_and_responsibilities": [{"actor_name": "Only Name"}, "not an object"]}' ) summary = parse_summary_response(content) assert summary is not None @@ -119,5 +153,5 @@ def test_contextual_orchestrator_summarizes_a_non_trivial_post() -> None: # block -- not just an English sentence handed back unchanged. assert any("가" <= ch <= "힣" for ch in summary.korean_summary) assert len(summary.key_events) >= 1 - people_named = {rr.person_name for rr in summary.roles_and_responsibilities} + people_named = {rr.actor_name for rr in summary.roles_and_responsibilities} assert any("Jordan" in name or "Priya" in name for name in people_named) From 7e1c0a27b28b8258a0ba5b40e5af7cdff3b61228 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:09:00 +0900 Subject: [PATCH 002/161] fix: keep R&R examples synthetic and validate actor_type_code Drop real-organization names from docs, prompts, and comments. Seed a synthetic organization actor so the Person/Organization badge is visible without a live LLM, and reject unknown actor_type_code values. --- ARCHITECTURE.md | 7 ++--- CHANGELOG.md | 13 ++------- backend/app/post_summary_ingestion.py | 7 ++++- backend/tests/test_api.py | 5 +++- ...0006-role-responsibility-agent-ontology.md | 24 ++++++--------- docs/ontology/lineageweave-kg.ttl | 6 ++-- frontend/src/App.test.tsx | 8 +++++ lineageweave/post_summary.py | 29 ++++++++++++------- migrations/0001_initial_schema.sql | 2 +- .../0012_role_responsibility_agent_type.sql | 4 +-- tests/test_post_summary.py | 6 ++++ 11 files changed, 62 insertions(+), 49 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 754ebed5..8933e01a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -685,11 +685,10 @@ and the latter `verify_uncorroborated` with none. ## Phase 7: R&R's named actor is a PROV-O Agent, not always a person -Confirmed against real Milestone 2 SAP CRM VOC data, not a hypothetical: `post_summary.py`'s R&R extraction forced every named actor into a -person slot, but real business correspondence routinely names an -organization acting in its own name ("당사" [our company], "SEWA," -"Siemens," "GECO"), not an individual. See +person slot, but business correspondence routinely names an +organization acting in its own name ("당사" [our company], +"Demo Corp"), not an individual. See [ADR 0006](docs/adr/0006-role-responsibility-agent-ontology.md). Grounded in W3C PROV-O (Lebo, Sahoo, & McGuinness, 2013): diff --git a/CHANGELOG.md b/CHANGELOG.md index 48b79dfd..09028f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,9 @@ All notable changes to this project are documented here. Format follows ### Changed -- R&R's named actor is no longer forced into a person slot. A real +- R&R's named actor is no longer forced into a person slot. A business post can name an organization acting in its own name - ("당사," "SEWA," "Siemens," "GECO"), not an individual -- + ("당사," "Demo Corp"), not an individual -- `RoleResponsibility.actor_name` (renamed from `person_name`) now carries `actor_type_code` (Person / Organization, W3C PROV-O grounded) and an LLM-inferred `affiliated_organization_name` for @@ -18,15 +18,6 @@ All notable changes to this project are documented here. Format follows badge and the inferred affiliation; only a person actor still links to the Keyman panel. See ADR 0006. -### Fixed - -- Applied migrations 0005-0011 (post evaluation, period reports, FIPC - linking, shared metric bank, report item information, persisted - chat) to the long-running demo database -- these had accumulated on - `main` without ever being applied to the running demo Postgres - volume, surfacing as CORS-looking failures (missing-table 500s - without CORS headers) on Evaluate, Reports, Summary, and Chat. - ## [0.67.0] - 2026-08-14 ### Added diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 366195a3..fa39b403 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -8,7 +8,7 @@ from lineageweave.fixtures import fixture_thread_cast from lineageweave.ontology import ontology_annotations -from lineageweave.post_summary import PostSummary, RoleResponsibility +from lineageweave.post_summary import ACTOR_TYPE_ORGANIZATION, PostSummary, RoleResponsibility async def fetch_persisted_summary(conn: asyncpg.Connection, post_id: str) -> dict[str, Any] | None: @@ -96,6 +96,11 @@ def seeded_demo_summary() -> PostSummary: responsibility="고객 측 수신", affiliated_organization_name="Northridge Grid", ), + RoleResponsibility( + actor_name="당사", + responsibility="출하 일정 확정", + actor_type_code=ACTOR_TYPE_ORGANIZATION, + ), ), ) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 0d3639b1..56588b65 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -414,7 +414,10 @@ def test_seed_demo_summary_surfaces_on_get_summary(client, demo_analyst_token, s body = response.json() assert "에이다" in body["korean_summary"] assert body["key_events"] - assert any(role["actor_name"] == "Ada West" for role in body["roles_and_responsibilities"]) + roles = {role["actor_name"]: role for role in body["roles_and_responsibilities"]} + assert roles["Ada West"]["actor_type_code"] == "prov_person" + assert roles["당사"]["actor_type_code"] == "prov_organization" + assert roles["당사"]["ontology_label"] == "Role actor (organization)" def test_seed_fixture_summaries_surface_on_get_summary(client, demo_analyst_token, seeded_db) -> None: diff --git a/docs/adr/0006-role-responsibility-agent-ontology.md b/docs/adr/0006-role-responsibility-agent-ontology.md index 45b8008e..8ded02b8 100644 --- a/docs/adr/0006-role-responsibility-agent-ontology.md +++ b/docs/adr/0006-role-responsibility-agent-ontology.md @@ -5,18 +5,13 @@ ## Context -The product brief flags a real gap in `post_summary.py`'s R&R (roles & -responsibilities) extraction, confirmed against real SAP CRM VOC records -during Milestone 2 analysis: the acting party a post's text names is not -always a person. Real business correspondence routinely names an -organization acting in its own name -- "당사" (our company), "SEWA," -"Siemens," "GECO" -- not a named individual. The brief's own wording: -"주체가 사람이 아니라 기관 ... 으로 나타나는 경우도 있으므로 일반적인 -표준 Ontology로 조치할 것" (the acting subject sometimes appears as an -organization rather than a person, so handle it with a general standard -Ontology), plus "사람만 넣어서는 소속 기관을 이해하기 어려우므로 소속 -기관 추론까지 포함시킬 것" (a bare person name is hard to place without -their organization, so infer the affiliation too). +`post_summary.py`'s R&R (roles & responsibilities) extraction treats +the acting party a post's text names as if it were always a person. +Business correspondence routinely names an organization acting in its +own name -- "당사" (our company), "Demo Corp" -- not a named +individual. The product requirement is to handle that with a general +standard ontology, and to infer a person actor's affiliation so a +bare name is not left unplaced. Before this change, `RoleResponsibility.person_name` had no way to express "this actor is an organization" -- every entry was forced into @@ -84,9 +79,8 @@ parentheses; only a person actor is still linked to the Keyman panel - `RoleResponsibility.person_name` is a breaking rename to `actor_name` across the JSON wire contract (`GET /api/posts/{id}/summary`), the DB column, and every call site. Accepted because the field's old name - was actively misleading once an organization actor is a real, - intended value, not a hypothetical edge case -- confirmed against - real Milestone 2 SAP CRM VOC data. + was actively misleading once an organization actor is an intended + value, not a hypothetical edge case. - `prov_agent_type` is a `common_lookup_value` category seeded by its own migration file (0012), not literally embedded in `scripts/seed_demo_data.py`'s SQL string the way ADR 0004's original diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 88436816..032773c4 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -160,9 +160,9 @@ # Classes -- prov_agent_type (post_summary_role.actor_type_code) # # A post's R&R (roles & responsibilities) actor is not always a person -# -- real business correspondence routinely names an organization -# acting in its own name ("당사" [our company], "SEWA," "Siemens," -# "GECO"). Grounded directly in W3C PROV-O (Lebo, Sahoo, & McGuinness, +# -- business correspondence routinely names an organization acting +# in its own name ("당사" [our company], "Demo Corp"). Grounded +# directly in W3C PROV-O (Lebo, Sahoo, & McGuinness, # 2013): prov:Agent is the general acting-party class, with prov:Person # and prov:Organization its two recognized subclasses. These are # distinct from :Person / :OurSidePerson / :CounterpartyPerson above: diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 747f4220..36cd8164 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -465,6 +465,12 @@ describe("App, authenticated", () => { actor_type_code: "prov_person", affiliated_organization_name: "Northridge Grid", }, + { + actor_name: "당사", + responsibility: "출하 일정 확정", + actor_type_code: "prov_organization", + affiliated_organization_name: null, + }, ], }), ); @@ -819,6 +825,8 @@ describe("App, authenticated", () => { expect(screen.getByText("첫 번째 이벤트")).toBeInTheDocument(); expect(screen.getByText(/우리 측 후속/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "R&R Keyman: Ada West" })).toBeInTheDocument(); + expect(screen.getByText("당사").closest("li")).toHaveTextContent("Organization"); + expect(screen.queryByRole("button", { name: "R&R Keyman: 당사" })).not.toBeInTheDocument(); await waitFor(() => expect(screen.getByText("간접")).toBeInTheDocument()); expect(screen.getByText("간접").closest("li")).toHaveTextContent("Linked post"); // The popup Event Lineage is the same A-100 reconstruct DAG as the home diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py index e229081c..65169eed 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -15,13 +15,13 @@ - **R&R (roles & responsibilities)**: semantic role labeling (Gildea & Jurafsky, 2002) -- who did what, framed as an agent/action/responsibility triple per named actor in the post, not prose. The actor is not always - a person: real business correspondence routinely names an organization - as the acting party ("당사" [our company], "SEWA," "Siemens," "GECO"), - not an individual. Modeling every actor as a person loses this - distinction and makes an organization's affiliation-less name look - like an unresolved person. Grounded in W3C PROV-O (Lebo, Sahoo, & - McGuinness, 2013): ``prov:Agent`` is the general acting-party class, - with ``prov:Person`` and ``prov:Organization`` as its two recognized + a person: business correspondence routinely names an organization + as the acting party ("당사" [our company], "Demo Corp"), not an + individual. Modeling every actor as a person loses this distinction + and makes an organization's affiliation-less name look like an + unresolved person. Grounded in W3C PROV-O (Lebo, Sahoo, & McGuinness, + 2013): ``prov:Agent`` is the general acting-party class, with + ``prov:Person`` and ``prov:Organization`` as its two recognized subclasses -- the same distinction ``keyman_extraction``'s two-sided (our-side/counterparty) person model already keeps for *people*, one level up. A person actor also gets an inferred @@ -73,6 +73,13 @@ class RoleResponsibility: actor_type_code: str = ACTOR_TYPE_PERSON affiliated_organization_name: str | None = None + def __post_init__(self) -> None: + if self.actor_type_code not in _VALID_ACTOR_TYPE_CODES: + raise ValueError( + f"actor_type_code must be one of {sorted(_VALID_ACTOR_TYPE_CODES)}, " + f"got {self.actor_type_code!r}" + ) + @dataclass(frozen=True) class PostSummary: @@ -118,10 +125,10 @@ def summarize(self, post_title: str, post_body: str) -> PostSummary: each as a short phrase. 3. A list of roles & responsibilities: for each named actor in the post -- a person OR an organization acting in its own name (e.g. "당사" - [our company], "SEWA," "Siemens," "GECO") -- one short phrase - describing what they are responsible for or did, according to the - text. Do not force an organization's name into a person slot: decide - whether each actor is a person or an organization, and say which. + [our company], "Demo Corp") -- one short phrase describing what they + are responsible for or did, according to the text. Do not force an + organization's name into a person slot: decide whether each actor is + a person or an organization, and say which. When the actor is a person and the text names or clearly implies who they work for, also give that organization's name -- a bare person name without their employer is hard to place. diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index c7391219..ddb7da4f 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -214,7 +214,7 @@ create table post_summary_event ( -- actor_type_code: R&R Ontology, see migrations/0012_role_responsibility_agent_type.sql -- and ADR 0006 -- a named actor is not always a person (an organization --- can act in its own name, e.g. "당사," "SEWA"), so this is not folded +-- can act in its own name, e.g. "당사," "Demo Corp"), so this is not folded -- into person_name's own meaning. create table post_summary_role ( post_id uuid not null references post_summary_result (post_id) on delete cascade, diff --git a/migrations/0012_role_responsibility_agent_type.sql b/migrations/0012_role_responsibility_agent_type.sql index 30b483b0..a46715fd 100644 --- a/migrations/0012_role_responsibility_agent_type.sql +++ b/migrations/0012_role_responsibility_agent_type.sql @@ -1,6 +1,6 @@ --- Roles & responsibilities' named actor is not always a person -- real +-- Roles & responsibilities' named actor is not always a person -- -- business correspondence routinely names an organization acting in its --- own name ("당사" [our company], "SEWA," "Siemens," "GECO"), not an +-- own name ("당사" [our company], "Demo Corp"), not an -- individual. Adds a PROV-O-grounded person/organization distinction -- (see ADR 0006) plus an inferred affiliated-organization name for -- person actors. The rename below (person_name -> actor_name) preserves diff --git a/tests/test_post_summary.py b/tests/test_post_summary.py index 7812df03..3d8a9ac9 100644 --- a/tests/test_post_summary.py +++ b/tests/test_post_summary.py @@ -24,6 +24,7 @@ from lineageweave.post_summary import ( ContextualOrchestratorPostSummaryClient, NullPostSummaryClient, + RoleResponsibility, parse_summary_response, ) @@ -71,6 +72,11 @@ def test_organization_actor_is_not_forced_into_a_person_slot() -> None: assert role.affiliated_organization_name is None +def test_unknown_actor_type_code_is_rejected() -> None: + with pytest.raises(ValueError, match="actor_type_code"): + RoleResponsibility(actor_name="Ada West", responsibility="후속", actor_type_code="person") + + def test_missing_actor_type_defaults_to_person() -> None: content = ( '{"korean_summary": "요약", "key_events": [], ' From 57b217d9d31955f91c2b94a954f6923ba6c0e878 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:11:49 +0900 Subject: [PATCH 003/161] feat: capture Keyman job title and avoid same-name merges (v0.69.0) PersonMention now carries an optional job_title extracted by the LLM from role phrasing (e.g. "our legal counsel, Sam Okonkwo"), not just named affiliations. cataloged_person.last_known_job_title persists it, and _upsert_person treats a conflicting stated title as evidence that a same-name match is a different real person rather than a re-mention, so two "Kim Cheolsu"s with different titles get distinct person rows. Keyman panel renders the title next to the person and per-affiliation role_title, which existed in the schema but was never surfaced before. Migration 0013 adds the column additively; 0001_initial_schema.sql bakes it in for fresh installs, matching this repo's existing pattern. --- ARCHITECTURE.md | 27 +++++++++++ CHANGELOG.md | 17 +++++++ backend/app/keyman_ingestion.py | 65 ++++++++++++++++++++------ backend/app/knowledge_graph.py | 3 +- backend/tests/test_api.py | 70 ++++++++++++++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.css | 6 +++ frontend/src/App.tsx | 6 +++ frontend/src/api.ts | 1 + lineageweave/__init__.py | 2 +- lineageweave/keyman_extraction.py | 37 ++++++++++++--- migrations/0001_initial_schema.sql | 6 +++ migrations/0013_person_job_title.sql | 11 +++++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 1 + tests/test_keyman_extraction.py | 26 +++++++++++ 16 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 migrations/0013_person_job_title.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8933e01a..b1110dba 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -709,3 +709,30 @@ actor is a free-text name with no cataloged identity of its own. rows) rather than a drop/recreate. The popup's R&R list shows a Person/Organization badge and the inferred affiliation; only a person actor still links to the Keyman panel. + +## Phase 8: same-name Keymen are not silently merged; titles are captured + +Two different real people can share a name -- `keyman_extraction.py` +never captured a stated job title/position, so nothing distinguished +"Kim Cheolsu, sales manager" from an unrelated "Kim Cheolsu, purchasing +lead" beyond the bare name. `PersonMention` gains `job_title: str | +None`, and the extraction prompt now explicitly asks for one when the +text states it (never left out as a same-name disambiguation signal). + +Persistence, in two places for a reason: `person_affiliation.role_title` +(a schema column that already existed, previously never populated) for +a title tied to a specific organization, and a new +`cataloged_person.last_known_job_title` (`migrations/0013_person_job_title.sql`) +for a title stated without a named organization to attach it to (e.g. +"our legal counsel, Sam Okonkwo" -- `fixtures.ambiguous_keyman_post()`'s +own real example, which has zero affiliated organizations for Sam). +Both feed `_upsert_person`'s disambiguation check +(`backend/app/keyman_ingestion.py`): a same person_name+person_side_code +match is only reused when the new mention's stated title, if any, does +not conflict with a title already on file -- a genuine stated conflict +creates a fresh `cataloged_person` row instead of merging two different +people. A missing title on either side is not treated as a conflict +(titles legitimately change -- a promotion -- and most mentions state no +title at all), so this only splits on an actual stated disagreement, +verified by a real test that two posts naming the same name with +genuinely different stated titles produce two distinct person rows. diff --git a/CHANGELOG.md b/CHANGELOG.md index 09028f0d..b51aaaa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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.69.0] - 2026-08-14 + +### Added + +- Keyman extraction now captures a stated job title/position + (`PersonMention.job_title`), since two different real people can + share a name and a title is real evidence for telling them apart. + Persisted to `person_affiliation.role_title` (an existing schema + column, previously never populated) and a new + `cataloged_person.last_known_job_title` for a title stated without a + named organization to attach it to. +- `_upsert_person` no longer blindly merges a same-name+side match: a + genuinely conflicting stated title creates a fresh person row instead + of reusing one, verified by a real test with two posts naming the + same name and different titles. +- Keyman panel shows the person's title next to their name. + ## [0.68.0] - 2026-08-14 ### Changed diff --git a/backend/app/keyman_ingestion.py b/backend/app/keyman_ingestion.py index e5451a9b..23f1027e 100644 --- a/backend/app/keyman_ingestion.py +++ b/backend/app/keyman_ingestion.py @@ -6,10 +6,22 @@ `person_affiliation` (N:N, matched to a real `corporate_entity` via similarity-based resolution -- see `lineageweave.corporate_hierarchy_resolution`, so an abbreviation or -trailing legal suffix still resolves, not just an exact string match), -and `post_person_mention`. Finishes by calling -`knowledge_graph.persist_edges_for_post` so the Knowledge Graph edges are -computed from the same write, not a separate manual step. +trailing legal suffix still resolves, not just an exact string match -- +plus `role_title`, a schema column that already existed and was +previously never populated by this pipeline), and `post_person_mention`. +Finishes by calling `knowledge_graph.persist_edges_for_post` so the +Knowledge Graph edges are computed from the same write, not a separate +manual step. + +Same-name disambiguation: `_upsert_person`'s name+side match is a real, +known simplification (documented above), but a stated job title is real +evidence a same-name match should NOT blindly trust -- when the new +mention names a title that conflicts with a title already on file for +that name+side (both stated, genuinely different), a fresh +`cataloged_person` row is created rather than merging two people who +happen to share a name. A person's title legitimately changes over time +(a promotion), so this only splits on an actual stated conflict, never +on a missing title on either side. """ from __future__ import annotations @@ -34,18 +46,41 @@ async def _load_corporate_entity_candidates(conn: asyncpg.Connection) -> list[Co async def _upsert_person(conn: asyncpg.Connection, mention: PersonMention) -> str: - """Reuse a same-name, same-side row so re-extraction does not duplicate.""" - row = await conn.fetchrow( - "select person_id from cataloged_person where person_name = $1 and person_side_code = $2", + """Reuse a same-name, same-side row so re-extraction does not duplicate + -- unless the new mention's stated job title conflicts with a title + already on file for that name+side (`last_known_job_title`, checked + even when this mention names no affiliated organization -- a title + is real same-name-disambiguation evidence on its own, see module + docstring), in which case a same name is not trusted as the same + real person. + """ + candidates = await conn.fetch( + "select person_id, last_known_job_title from cataloged_person " + "where person_name = $1 and person_side_code = $2", mention.person_name, mention.person_side_code, ) - if row is not None: - return str(row["person_id"]) + if candidates and mention.job_title: + for candidate in candidates: + on_file = candidate["last_known_job_title"] + if on_file is not None and on_file != mention.job_title: + continue # stated title conflicts -- do not reuse this row + if on_file is None: + await conn.execute( + "update cataloged_person set last_known_job_title = $1 where person_id = $2", + mention.job_title, + candidate["person_id"], + ) + return str(candidate["person_id"]) + elif candidates: + return str(candidates[0]["person_id"]) + row = await conn.fetchrow( - "insert into cataloged_person (person_name, person_side_code) values ($1, $2) returning person_id", + "insert into cataloged_person (person_name, person_side_code, last_known_job_title) " + "values ($1, $2, $3) returning person_id", mention.person_name, mention.person_side_code, + mention.job_title, ) return str(row["person_id"]) @@ -77,14 +112,18 @@ async def ingest_post_keymen( corporate_entity_id = resolve_corporate_entity(organization_name, candidates) await conn.execute( """ - insert into person_affiliation (person_id, affiliated_organization_name, affiliated_corporate_entity_id) - values ($1, $2, $3) + insert into person_affiliation + (person_id, affiliated_organization_name, affiliated_corporate_entity_id, role_title) + values ($1, $2, $3, $4) on conflict (person_id, affiliated_organization_name) - do update set affiliated_corporate_entity_id = excluded.affiliated_corporate_entity_id + do update set + affiliated_corporate_entity_id = excluded.affiliated_corporate_entity_id, + role_title = coalesce(excluded.role_title, person_affiliation.role_title) """, person_id, organization_name, corporate_entity_id, + mention.job_title, ) if mentions: diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index bb398d14..97f45a9d 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -63,7 +63,7 @@ async def fetch_post_keymen(conn: asyncpg.Connection, post_id: str) -> list[dict """Load mentioned people and their affiliations for one post.""" person_rows = await conn.fetch( """ - select p.person_id, p.person_name, p.person_side_code, ppm.mention_context + select p.person_id, p.person_name, p.person_side_code, p.last_known_job_title, ppm.mention_context from post_person_mention ppm join cataloged_person p on p.person_id = ppm.person_id where ppm.post_id = $1 @@ -105,6 +105,7 @@ async def fetch_post_keymen(conn: asyncpg.Connection, post_id: str) -> list[dict "person_side_code": row["person_side_code"], "person_side_label": side_labels.get(row["person_side_code"], row["person_side_code"]), "mention_context": row["mention_context"], + "last_known_job_title": row["last_known_job_title"], "affiliations": affiliations_by_person.get(str(row["person_id"]), []), } for row in person_rows diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 56588b65..3884d171 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -957,6 +957,76 @@ def test_extract_keymen_requires_post_admin(client, demo_analyst_token, seeded_d _ORCHESTRATOR_API_KEY = os.environ.get("LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY") +def test_extract_keymen_does_not_merge_same_name_people_with_conflicting_titles( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Two different real people can share a name -- extracting a second + post that names the same person_name+side but a genuinely different + stated job_title must NOT reuse the first post's cataloged_person row. + A deterministic fake client (not a real orchestrator call) so this + is CI-stable: the point under test is `_upsert_person`'s own SQL + logic, not LLM extraction quality. + """ + from lineageweave.keyman_extraction import COUNTERPARTY, PersonMention + + _grant_post_admin(seeded_db["dsn"]) + + class _FakeClient: + available = True + + def __init__(self, job_title: str) -> None: + self._job_title = job_title + + def extract(self, post_title: str, post_body: str) -> list[PersonMention]: + return [PersonMention(person_name="Kim Cheolsu", person_side_code=COUNTERPARTY, job_title=self._job_title)] + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + post_ids = [] + for title in ("Sales follow-up", "Purchasing follow-up"): + cur.execute( + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) " + "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' " + "from source_post where post_id = %s " + "returning post_id", + (title, "placeholder body", seeded_db["own_private_post_id"]), + ) + post_ids.append(str(cur.fetchone()[0])) + finally: + admin_conn.close() + + monkeypatch.setattr("backend.app.main._entity_relationship_client", lambda: _FakeClient("unused")) + + monkeypatch.setattr("backend.app.main._keyman_extraction_client", lambda: _FakeClient("Sales Manager")) + response_a = client.post( + f"/api/posts/{post_ids[0]}/extract-keymen", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response_a.status_code == 200, response_a.text + + monkeypatch.setattr("backend.app.main._keyman_extraction_client", lambda: _FakeClient("Purchasing Lead")) + response_b = client.post( + f"/api/posts/{post_ids[1]}/extract-keymen", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response_b.status_code == 200, response_b.text + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "select count(distinct person_id) from cataloged_person where person_name = 'Kim Cheolsu'" + ) + distinct_people = cur.fetchone()[0] + finally: + admin_conn.close() + + assert distinct_people == 2, "conflicting stated job titles for the same name must not be merged into one person" + + @pytest.mark.skipif( not (_ORCHESTRATOR_BASE_URL and _ORCHESTRATOR_API_KEY), reason="set LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL and LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY to run", diff --git a/frontend/package.json b/frontend/package.json index 0e8e1c3e..3ae022b9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.68.0", + "version": "0.69.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 177fcf7c..da74389c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -239,6 +239,12 @@ font-size: 0.85rem; } +.keyman-role-title { + opacity: 0.6; + font-size: 0.8rem; + font-style: italic; +} + .verification-badge { font-size: 0.8rem; padding: 0.1rem 0.5rem; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be899929..043a6542 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -623,6 +623,9 @@ function KeymanPanel({ > {person.person_name} ({person.person_side_label ?? person.person_side_code}) + {person.last_known_job_title && ( + {person.last_known_job_title} + )} {person.affiliations.length > 0 && ( {" -- "} @@ -645,6 +648,9 @@ function KeymanPanel({ ) : ( affiliation.organization_name )} + {affiliation.role_title && ( + ({affiliation.role_title}) + )} ))} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 03d7a71e..3a8d5275 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -26,6 +26,7 @@ export interface Keyman { person_side_code: string; person_side_label?: string; mention_context: string | null; + last_known_job_title: string | null; affiliations: Affiliation[]; } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 90bd896b..83c985d4 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.68.0" +__version__ = "0.69.0" diff --git a/lineageweave/keyman_extraction.py b/lineageweave/keyman_extraction.py index d7e8de95..f853e2f9 100644 --- a/lineageweave/keyman_extraction.py +++ b/lineageweave/keyman_extraction.py @@ -35,14 +35,26 @@ class PersonMention: """One person the extractor found in a post's text. - ``affiliated_organization_names`` may be empty (mentioned without a - stated affiliation) or contain more than one name (the N:N case the - product requirement describes). + Attributes: + affiliated_organization_names: may be empty (mentioned without a + stated affiliation) or contain more than one name (the N:N + case the product requirement describes). + job_title: the person's title/position as the text states it + (e.g. "영업팀장," "구매담당"), or ``None`` when the text does + not say. Two different real people can share a name -- a + name alone is not a reliable identity key, and dropping a + stated title would throw away the one signal the text + offers to tell them apart. Persisted onto + ``person_affiliation.role_title`` (a schema column that + already existed, previously never populated) and used by + ``_upsert_person`` as a same-name disambiguation signal: + see ``backend/app/keyman_ingestion.py``. """ person_name: str person_side_code: str affiliated_organization_names: tuple[str, ...] = field(default_factory=tuple) + job_title: str | None = None class KeymanExtractionClient(Protocol): @@ -71,9 +83,13 @@ def extract(self, post_title: str, post_body: str) -> list[PersonMention]: _EXTRACTION_PROMPT_TEMPLATE = """\ Read the post below and list every named person it mentions. For each -person, classify which side they are on and list every organization they +person, classify which side they are on, list every organization they are affiliated with according to the text (a person may belong to more -than one organization, or none if the text does not say). +than one organization, or none if the text does not say), and give their +job title or position if the text states one. Two different real people +can share the same name -- a stated title/position (e.g. "sales +manager," "purchasing lead") is real evidence for telling them apart, so +report it whenever the text gives one rather than leaving it out. Reply with ONLY a JSON array (no markdown fences, no prose), where each element has exactly these fields: @@ -82,6 +98,8 @@ def extract(self, post_title: str, post_body: str) -> list[PersonMention]: "counterparty" (an external customer, partner, competitor, or other outside organization) "affiliations": a JSON array of organization name strings (can be empty) + "job_title": the person's stated title/position as a string, or null + when the text does not give one If no people are named, reply with an empty JSON array: [] @@ -126,8 +144,15 @@ def parse_keyman_response(content: str) -> list[PersonMention]: if not isinstance(affiliations_raw, list): affiliations_raw = [] affiliations = tuple(a.strip() for a in affiliations_raw if isinstance(a, str) and a.strip()) + job_title_raw = entry.get("job_title") + job_title = job_title_raw.strip() if isinstance(job_title_raw, str) and job_title_raw.strip() else None mentions.append( - PersonMention(person_name=name.strip(), person_side_code=side, affiliated_organization_names=affiliations) + PersonMention( + person_name=name.strip(), + person_side_code=side, + affiliated_organization_names=affiliations, + job_title=job_title, + ) ) return mentions diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index ddb7da4f..372b2048 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -333,10 +333,16 @@ create table report_item_information ( -- --------------------------------------------------------------------- -- Cataloged people mentioned in posts (Keyman). Named cataloged_person, -- not person, so every table name is two or more snake_case words. +-- last_known_job_title: the disambiguation signal migrations/0013 adds. +-- Lives here, not only on person_affiliation.role_title, because a +-- stated title ("our legal counsel, Sam Okonkwo") is real same-name +-- evidence even when the text names no specific organization to attach +-- a person_affiliation row to. create table cataloged_person ( person_id uuid primary key default uuid_generate_v4(), person_name text not null, person_side_code text not null references common_lookup_value (lookup_code), + last_known_job_title text, created_at timestamptz not null default now() ); diff --git a/migrations/0013_person_job_title.sql b/migrations/0013_person_job_title.sql new file mode 100644 index 00000000..5904a6e0 --- /dev/null +++ b/migrations/0013_person_job_title.sql @@ -0,0 +1,11 @@ +-- Same-name-people disambiguation signal: a stated job title/position is +-- real evidence a same person_name+person_side_code match is NOT the +-- same real individual. Lives on cataloged_person itself, not only +-- person_affiliation.role_title, because a title is real disambiguation +-- evidence even when the text names no specific organization to attach +-- an affiliation row to (e.g. "our legal counsel, Sam Okonkwo"). +-- ADD COLUMN IF NOT EXISTS so a volume that already ran 0001 still +-- upgrades. + +alter table cataloged_person + add column if not exists last_known_job_title text; diff --git a/pyproject.toml b/pyproject.toml index 1b0e694a..d5ee2dac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.68.0" +version = "0.69.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 59e487d1..f1d15891 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -107,6 +107,7 @@ def seed( cur.execute((migrations / "0010_report_item_information.sql").read_text()) cur.execute((migrations / "0011_post_chat_result.sql").read_text()) cur.execute((migrations / "0012_role_responsibility_agent_type.sql").read_text()) + cur.execute((migrations / "0013_person_job_title.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values diff --git a/tests/test_keyman_extraction.py b/tests/test_keyman_extraction.py index b1ab49da..d9de4571 100644 --- a/tests/test_keyman_extraction.py +++ b/tests/test_keyman_extraction.py @@ -47,6 +47,24 @@ def test_parses_a_well_formed_json_array() -> None: assert mentions[1].affiliated_organization_names == ("Acme Corp", "Acme Holdings") +def test_job_title_is_captured_when_present() -> None: + content = '[{"name": "Kim Cheolsu", "side": "counterparty", "affiliations": [], "job_title": "Sales Manager"}]' + mentions = parse_keyman_response(content) + assert mentions[0].job_title == "Sales Manager" + + +def test_job_title_is_none_not_empty_string_when_absent() -> None: + content = '[{"name": "Kim Cheolsu", "side": "counterparty", "affiliations": []}]' + mentions = parse_keyman_response(content) + assert mentions[0].job_title is None + + +def test_null_job_title_is_none_not_the_string_null() -> None: + content = '[{"name": "Kim Cheolsu", "side": "counterparty", "affiliations": [], "job_title": null}]' + mentions = parse_keyman_response(content) + assert mentions[0].job_title is None + + def test_strips_a_markdown_code_fence() -> None: content = '```json\n[{"name": "Jo Park", "side": "our_side", "affiliations": []}]\n```' mentions = parse_keyman_response(content) @@ -110,3 +128,11 @@ def test_contextual_orchestrator_extracts_keymen_from_an_ambiguous_post() -> Non assert jordan.person_side_code == OUR_SIDE assert priya.person_side_code == COUNTERPARTY assert len(priya.affiliated_organization_names) >= 2 + + # Sam Okonkwo is named only by role ("our legal counsel, Sam Okonkwo") -- + # a real assertion that job_title extraction reads the text, not a + # synthetic fixture built just to satisfy this one field. + sam = next((m for name, m in by_name.items() if "Sam" in name or "Okonkwo" in name), None) + assert sam is not None + assert sam.job_title is not None + assert "counsel" in sam.job_title.lower() or "legal" in sam.job_title.lower() From 94bf669994169e06bda24730c04edd2fb3597947 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 11:22:02 +0900 Subject: [PATCH 004/161] fix: seed synthetic Keyman titles so the panel is not empty After make seed, Ada West / Priya Nair / Jordan Hale carry last_known_job_title so the new title chip is visible without a live extraction. --- CHANGELOG.md | 4 +++- frontend/src/App.test.tsx | 2 ++ scripts/seed_demo_data.py | 24 +++++++++++++++--------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b51aaaa6..6b65c7bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,9 @@ All notable changes to this project are documented here. Format follows genuinely conflicting stated title creates a fresh person row instead of reusing one, verified by a real test with two posts naming the same name and different titles. -- Keyman panel shows the person's title next to their name. +- Keyman panel shows the person's title next to their name. After + `make seed`, Ada West is "Account manager" and Priya Nair is + "Procurement lead" so the title is visible without a live LLM. ## [0.68.0] - 2026-08-14 diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 36cd8164..4961e5a1 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -484,6 +484,7 @@ describe("App, authenticated", () => { person_name: "Ada West", person_side_code: "our_side", person_side_label: "Our side", + last_known_job_title: "Account manager", mention_context: null, affiliations: [{ organization_name: "Demo Corp", corporate_entity_id: "corp-1", role_title: null }], }, @@ -973,6 +974,7 @@ describe("App, authenticated", () => { expect(screen.getByRole("button", { name: "Keyman affiliation: Demo Corp" })).toBeInTheDocument(); expect(screen.getByText("(Company)")).toBeInTheDocument(); expect(screen.getAllByText(/Ada West \(Our side\)/).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Account manager")).toBeInTheDocument(); expect(screen.queryByText(/our_side/)).not.toBeInTheDocument(); expect(screen.getByText("unresolved")).toBeInTheDocument(); expect(screen.getByText(/Voice of Customer\s*\(voc\)/)).toBeInTheDocument(); diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index f1d15891..098e0b50 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -253,8 +253,9 @@ def seed( from lineageweave.knowledge_graph import knowledge_graph_edges_for_post cur.execute( - "insert into cataloged_person (person_name, person_side_code) values " - "('Ada West', 'our_side'), ('Priya Nair', 'counterparty') " + "insert into cataloged_person (person_name, person_side_code, last_known_job_title) values " + "('Ada West', 'our_side', 'Account manager'), " + "('Priya Nair', 'counterparty', 'Procurement lead') " "returning person_name, person_id" ) people = dict(cur.fetchall()) @@ -582,22 +583,27 @@ def _seed_fixture_evaluations(cur) -> None: def _ensure_demo_people(cur, corporate_entity_id) -> dict[str, str]: """Ada West / Priya Nair / Jordan Hale plus their affiliations. Idempotent.""" people: dict[str, str] = {} - for name, side in ( - ("Ada West", "our_side"), - ("Priya Nair", "counterparty"), - ("Jordan Hale", "our_side"), + for name, side, title in ( + ("Ada West", "our_side", "Account manager"), + ("Priya Nair", "counterparty", "Procurement lead"), + ("Jordan Hale", "our_side", "Bid coordinator"), ): cur.execute("select person_id from cataloged_person where person_name = %s", (name,)) row = cur.fetchone() if row is None: cur.execute( - "insert into cataloged_person (person_name, person_side_code) " - "values (%s, %s) returning person_id", - (name, side), + "insert into cataloged_person (person_name, person_side_code, last_known_job_title) " + "values (%s, %s, %s) returning person_id", + (name, side, title), ) people[name] = str(cur.fetchone()[0]) else: people[name] = str(row[0]) + cur.execute( + "update cataloged_person set last_known_job_title = coalesce(last_known_job_title, %s) " + "where person_id = %s", + (title, people[name]), + ) cur.execute( "insert into person_affiliation " "(person_id, affiliated_organization_name, affiliated_corporate_entity_id) " From cd01c7628f2c7c025b4c7516bc155cdeff2c1edc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 12:01:16 +0900 Subject: [PATCH 005/161] fix: read Keycloak admin password from the environment Strix flagged the local-dev password literal in seed_demo_data.py after this branch started editing that file. make seed still injects the compose default; a direct script run requires KEYCLOAK_ADMIN_PASSWORD. --- CHANGELOG.md | 7 +++++++ Makefile | 2 +- scripts/seed_demo_data.py | 14 ++++++++++---- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b65c7bc..6d6f63d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ All notable changes to this project are documented here. Format follows `make seed`, Ada West is "Account manager" and Priya Nair is "Procurement lead" so the title is visible without a live LLM. +### Fixed + +- `scripts/seed_demo_data.py` no longer embeds the local Keycloak admin + password. `make seed` still supplies the compose default via + `KEYCLOAK_ADMIN_PASSWORD`; a direct script run requires that env var + or `--keycloak-admin-password`. + ## [0.68.0] - 2026-08-14 ### Changed diff --git a/Makefile b/Makefile index 68ee850e..a827a53d 100644 --- a/Makefile +++ b/Makefile @@ -23,4 +23,4 @@ smoke: # users' real subject ids, plus Valkey ticket_created events so Activity # is not empty (see scripts/seed_demo_data.py). Run after `up`. seed: - python3 scripts/seed_demo_data.py + KEYCLOAK_ADMIN_PASSWORD=$${KEYCLOAK_ADMIN_PASSWORD:-admin_dev_only} python3 scripts/seed_demo_data.py diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 098e0b50..b13eb62a 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -13,12 +13,13 @@ HTTP goes through ``lineageweave.http_client`` (http(s) allowlist). -Usage: python3 scripts/seed_demo_data.py [--postgres-dsn ...] [--keycloak-base-url ...] [--valkey-url ...] +Usage: KEYCLOAK_ADMIN_PASSWORD=... python3 scripts/seed_demo_data.py [--postgres-dsn ...] [--keycloak-base-url ...] [--valkey-url ...] """ from __future__ import annotations import argparse +import os import sys from pathlib import Path from urllib.parse import urlencode @@ -33,8 +34,7 @@ REALM = "lineageweave-demo" DEFAULT_POSTGRES_DSN = "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave" DEFAULT_KEYCLOAK_BASE_URL = "http://localhost:18080" -DEFAULT_KEYCLOAK_ADMIN_USER = "admin" -DEFAULT_KEYCLOAK_ADMIN_PASSWORD = "admin_dev_only" # nosec B105 -- throwaway local-dev-only Keycloak seed credential +DEFAULT_KEYCLOAK_ADMIN_USER = os.environ.get("KEYCLOAK_ADMIN", "admin") DEFAULT_VALKEY_URL = "redis://localhost:16379/0" # (post_title, ticket_title, due_date) -- Event Lineage fixtures a report @@ -1172,9 +1172,15 @@ def main() -> None: parser.add_argument("--postgres-dsn", default=DEFAULT_POSTGRES_DSN) parser.add_argument("--keycloak-base-url", default=DEFAULT_KEYCLOAK_BASE_URL) parser.add_argument("--keycloak-admin-user", default=DEFAULT_KEYCLOAK_ADMIN_USER) - parser.add_argument("--keycloak-admin-password", default=DEFAULT_KEYCLOAK_ADMIN_PASSWORD) + parser.add_argument( + "--keycloak-admin-password", + default=os.environ.get("KEYCLOAK_ADMIN_PASSWORD"), + help="Keycloak master admin password (or KEYCLOAK_ADMIN_PASSWORD). Required.", + ) parser.add_argument("--valkey-url", default=DEFAULT_VALKEY_URL) args = parser.parse_args() + if not args.keycloak_admin_password: + parser.error("set KEYCLOAK_ADMIN_PASSWORD or pass --keycloak-admin-password") subjects = _fetch_demo_user_subjects(args.keycloak_base_url, args.keycloak_admin_user, args.keycloak_admin_password) seed(args.postgres_dsn, subjects, args.valkey_url) From cb0122af8641f0ccc462c545078317364f506538 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 12:18:28 +0900 Subject: [PATCH 006/161] feat: R&R actor can be a team, meso-level between person and org (v0.70.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real post text named a company sub-unit ("설계팀"/design team) that neither ADR 0006's prov_person nor prov_organization fits -- it's part of a company, not a person and not the company itself. actor_type_code gains prov_team, grounded in the W3C Organization Ontology's org:OrganizationalUnit (Reynolds, 2014), a different W3C vocabulary from PROV-O that exists specifically for this meso-level case. A team actor requires affiliated_organization_name in the same way a person actor does -- unlike an organization actor, a team's own name never answers "which company." Fixed a real bug the new type surfaced: the R&R badge's label text was a binary Person/Organization ternary that would have mislabeled a team as "Organization" (the CSS class name was already generic; the display text was not). Migration 0014 is purely additive (one lookup row insert), no schema change -- actor_type_code already stores an arbitrary FK'd code. --- ARCHITECTURE.md | 15 ++++ CHANGELOG.md | 15 ++++ backend/tests/test_api.py | 3 +- docs/adr/0007-team-actor-type.md | 90 +++++++++++++++++++ docs/ontology/lineageweave-kg.ttl | 15 ++++ frontend/package.json | 2 +- frontend/src/App.css | 5 ++ frontend/src/App.tsx | 8 +- lineageweave/__init__.py | 2 +- lineageweave/post_summary.py | 59 +++++++----- ...14_role_responsibility_team_actor_type.sql | 11 +++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 1 + tests/test_ontology.py | 40 ++++++--- tests/test_post_summary.py | 20 +++++ 15 files changed, 252 insertions(+), 36 deletions(-) create mode 100644 docs/adr/0007-team-actor-type.md create mode 100644 migrations/0014_role_responsibility_team_actor_type.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b1110dba..9d99da0c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -736,3 +736,18 @@ people. A missing title on either side is not treated as a conflict title at all), so this only splits on an actual stated disagreement, verified by a real test that two posts naming the same name with genuinely different stated titles produce two distinct person rows. + +## Phase 9: an R&R actor can be a team, meso-level between person and organization + +Real post text named "설계팀" (design team) -- neither a person nor the +company itself, but a sub-unit of one. See +[ADR 0007](docs/adr/0007-team-actor-type.md). `actor_type_code` gains a +third value, `prov_team`, grounded in the W3C Organization Ontology's +`org:OrganizationalUnit` (Reynolds, 2014) -- a different, complementary +W3C vocabulary from PROV-O (which models "who acted," not "how a +company is internally structured"). The prompt now offers three actor +types and requires `affiliated_organization_name` for a team actor too +(not just a person): a team's own name never answers "which company," +unlike an organization actor's. `migrations/0014_role_responsibility_team_actor_type.sql` +adds the lookup row -- purely additive, no schema change, since +`actor_type_code` already stores an arbitrary FK'd code. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6f63d5..505a0953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ 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.70.0] - 2026-08-14 + +### Added + +- R&R's `actor_type_code` gains `prov_team`, a meso-level actor type for + a named sub-unit of a company (e.g. "설계팀"/design team) -- distinct + from both a person and the company itself. Grounded in the W3C + Organization Ontology's `org:OrganizationalUnit` (Reynolds, 2014). +- A team actor now requires `affiliated_organization_name` in the same + way a person actor does -- a team's own name never answers "which + company." +- R&R badge shows a distinct "Team" label/color, not the prior binary + Person/Organization ternary (which would have mislabeled a team as + "Organization"). + ## [0.69.0] - 2026-08-14 ### Added diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3884d171..1d7f7f16 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -144,7 +144,8 @@ def seeded_db(demo_analyst_token): "('evaluation_criterion', 'general_sentiment_negative', 'Negative stance'), " "('evaluation_criterion', 'sales_lead_specificity', 'Sales-lead specificity'), " "('prov_agent_type', 'prov_person', 'Person'), " - "('prov_agent_type', 'prov_organization', 'Organization')" + "('prov_agent_type', 'prov_organization', 'Organization'), " + "('prov_agent_type', 'prov_team', 'Team')" ) cur.execute( "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " diff --git a/docs/adr/0007-team-actor-type.md b/docs/adr/0007-team-actor-type.md new file mode 100644 index 00000000..1da18883 --- /dev/null +++ b/docs/adr/0007-team-actor-type.md @@ -0,0 +1,90 @@ +# ADR 0007 — R&R's named actor can be a team, a meso-level unit, not just a person/organization + +**Decision status:** Accepted +**Date:** 2026-08-14 + +## Context + +ADR 0006 gave R&R's `actor_type_code` two values: `prov_person` and +`prov_organization`, grounded in W3C PROV-O's `prov:Agent` subclasses. +Real post text surfaced a third, distinct case those two do not cover: +a named sub-unit of a company -- e.g. "설계팀" (design team) -- acting +in the text. A team is not a person, and forcing it into +`prov_organization` is wrong for the same reason ADR 0006 rejected +forcing an organization into a person slot: it collapses a real, +useful distinction. A team is meso-level -- part of a company, not the +company itself, and not an individual either. + +PROV-O has no sub-organization concept to reuse here; `prov:Agent`'s +two subclasses are exhaustive for PROV-O's own purposes (an +organization's internal structure is out of PROV-O's scope). + +## Decision + +Ground the team case in the W3C Organization Ontology (Reynolds, 2014): +`org:OrganizationalUnit`, defined for exactly this -- representing the +division of an organization into sub-organizational units, linked to +its parent via `org:unitOf`/`org:subOrganizationOf`. This is a +different, complementary W3C vocabulary from PROV-O, not a conflicting +one: PROV-O models "who/what acted," ORG models "how an organization is +structured internally" -- a team acting in a post's text needs both a +`prov:Agent`-shaped role (it does something) and an +`org:OrganizationalUnit`-shaped identity (it belongs to a company). +`:RoleActorTeam` is declared `rdfs:subClassOf org:OrganizationalUnit` +for that reason, parallel to how `:RoleActorPerson`/ +`:RoleActorOrganization` subclass PROV-O's classes. + +`post_summary.py` gains `ACTOR_TYPE_TEAM = "prov_team"` +(`common_lookup_value` category `prov_agent_type`, extending ADR +0006's two existing values). The LLM prompt now offers three actor +types (person / organization / team) and explicitly requires a team +actor to also carry `affiliated_organization_name` -- unlike an +organization actor (whose own name already answers "which +organization"), a team's name alone does not identify a company, so +the field is not optional in the same "opportunistic" sense ADR 0006 +described for a person actor; a team is always someone's team, and the +prompt asks the model to infer the parent company from context when +the text supports it. + +No new `RoleResponsibility` field is needed: +`affiliated_organization_name` already exists (ADR 0006) and applies +unchanged to this actor type -- only its *meaning* extends from +"the person's employer" to "the person's or team's parent +organization," which the dataclass docstring now says explicitly. + +Persistence: `migrations/0014_role_responsibility_team_actor_type.sql` +inserts the `prov_team` lookup row -- purely additive +(`insert ... on conflict (lookup_code) do nothing`), no column or +constraint change, since `actor_type_code` already stores an arbitrary +FK'd lookup code and needs no schema change to accept a third value. + +## Consequences + +- `_VALID_ACTOR_TYPE_CODES` in `post_summary.py` now has three members; + any code elsewhere that pattern-matches strictly on the first two + (rather than treating an unrecognized/future code as "not this one") + needs review. Found and fixed one: the frontend badge's CSS class name + (`actor-type-${code}`) was already generic, but its *label text* was a + binary person/organization ternary that would have mislabeled a team + actor as "Organization" -- now a three-way check. +- A team actor is never linked to the Keyman panel (same as an + organization actor in ADR 0006) -- it has no `person_id`. +- Distinguishing "설계팀" (a team) from "Design Corp" (an organization) + is a real LLM judgment call with no hard syntactic rule; the prompt + gives the model the concept and an example, matching this repo's + existing degrade-gracefully discipline for judgment-call extraction + fields (a wrong guess is a labeling error on one row, not lost data -- + the raw `actor_name` string is preserved regardless of which type it + is filed under). + +## Related + +Extends [ADR 0006](0006-role-responsibility-agent-ontology.md), which +itself extends [ADR 0004](0004-knowledge-graph-ontology.md)'s Ontology/ +Semantic-Layer vocabulary. + +## References (APA 7th) + +Reynolds, D. (Ed.). (2014). *The organization ontology* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/vocab-org/ + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/ diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 032773c4..bb398a9a 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -5,6 +5,7 @@ @prefix skos: . @prefix xsd: . @prefix prov: . +@prefix org: . ################################################################# # LineageWeave Knowledge Graph Ontology @@ -169,6 +170,14 @@ # node_type's :Person is a cataloged_person row with a stable person_id # a Keyman panel links to; an R&R actor is a free-text name with no # cataloged identity of its own (it may not even resolve to a Keyman). +# +# A third, meso-level case real data surfaced: a named sub-unit of a +# company ("설계팀" [design team]) is neither prov:Person nor the +# prov:Organization itself -- it is the company's own internal +# structure. PROV-O has no such class; the W3C Organization Ontology +# (Reynolds, 2014) does: org:OrganizationalUnit, "used to represent +# division of a particular organization into sub-organizational units," +# linked to its parent via org:unitOf. See docs/adr/0007-team-actor-type.md. ################################################################# :RoleActorPerson a owl:Class ; @@ -182,3 +191,9 @@ rdfs:label "Role actor (organization)" ; rdfs:comment "An R&R actor that is an organization acting in its own name, per prov:Organization." ; :lookupCode "prov_organization" . + +:RoleActorTeam a owl:Class ; + rdfs:subClassOf org:OrganizationalUnit ; + rdfs:label "Role actor (team)" ; + rdfs:comment "An R&R actor that is a named sub-unit of a company (e.g. 설계팀), per org:OrganizationalUnit -- not the company itself." ; + :lookupCode "prov_team" . diff --git a/frontend/package.json b/frontend/package.json index 3ae022b9..22b879c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.69.0", + "version": "0.70.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index da74389c..b1717e10 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -269,6 +269,11 @@ color: #e65100; } +.actor-type-prov_team { + background: #e0f2f1; + color: #00695c; +} + .rr-affiliation { opacity: 0.7; font-size: 0.9rem; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 043a6542..47be3b71 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1183,13 +1183,19 @@ function PostDetailPopup({
      {summary.roles_and_responsibilities.map((rr, i) => { const isPerson = rr.actor_type_code === "prov_person"; + const actorTypeLabel = + rr.actor_type_code === "prov_team" + ? "Team" + : isPerson + ? "Person" + : "Organization"; const person = isPerson ? keymen?.find((row) => row.person_name === rr.actor_name) : undefined; return (
    • - {isPerson ? "Person" : "Organization"} + {actorTypeLabel} {" "} {person ? (
    • ); })}
    )} + {selected && ( +
    +

    {analysisRunCaption(selected)}

    +

    + Cutoff {selected.knowledge_cutoff.slice(0, 10)} + {" · "} + Requested {selected.requested_at.slice(0, 10)} +

    +
      + {selected.source_counts.map((count) => ( +
    • + {count.count_value} {count.count_type_label.toLowerCase()} +
    • + ))} +
    +
    + )} ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 91e9f65e..bc1c39e6 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -516,3 +516,7 @@ export interface AnalysisRun { export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { return backendFetch("/api/analysis-runs", accessToken); } + +export function fetchAnalysisRun(accessToken: string, analysisRunId: string): Promise { + return backendFetch(`/api/analysis-runs/${analysisRunId}`, accessToken); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index a8f40a3c..7b561a3a 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.79.0" +__version__ = "0.80.0" diff --git a/pyproject.toml b/pyproject.toml index 9f9ed853..57a3973a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.79.0" +version = "0.80.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index 6d9094f6..2c009d7e 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.79.0" +version = "0.80.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From e9bcd4858b0ce73984945722a0068201c925c9dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:07:57 +0900 Subject: [PATCH 101/161] feat: show labeled analysis-run status history (v0.81.0) (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buyer gap: after #100 the detail showed cutoff and counts but not the legal lifecycle the registry already stored. GET /api/analysis-runs/{id} now returns labeled status_history (Pending → Running → Succeeded with occurrence times). The list stays latest-status only. Hidden runs still 404 and never leak events. Failure codes stay machine tokens. Synthetic Demo Corp seed only. --- ARCHITECTURE.md | 9 ++++-- .../0.81.0-analysis-run-status-history.md | 5 ++++ CHANGELOG.md | 10 +++++++ backend/app/analysis_run_ingestion.py | 30 +++++++++++++++++++ backend/app/main.py | 5 +++- backend/tests/test_api.py | 19 ++++++++++-- docs/adr/0014-authorized-analysis-run-read.md | 8 +++-- frontend/package.json | 2 +- frontend/src/App.test.tsx | 24 +++++++++++++++ frontend/src/App.tsx | 10 +++++++ frontend/src/api.ts | 9 ++++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- 13 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 CHANGELOG.d/0.81.0-analysis-run-status-history.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e00b8c8b..33d9d2c0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -464,11 +464,14 @@ process-unit scope is visible only to affiliated accounts; a thread-group scope is visible only when the account can already see a post in that group; `all_visible` is requester-only. Hidden runs 404. The home list is clickable: `GET /api/analysis-runs/{id}` fills a -labeled detail (cutoff, requested date, counts) without exposing a -DSN or raw record. The payload is lookup labels plus non-negative aggregate counts -- never +labeled detail (cutoff, requested date, counts, status history) +without exposing a DSN or raw record. Status history is detail-only +and uses lookup labels plus occurrence times; a failure event keeps +its machine `failure_code` rather than an invented caption. The +payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · -Demo Corp" with "3 documents". +Demo Corp" with "3 documents" and Pending / Running / Succeeded times. ## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only) diff --git a/CHANGELOG.d/0.81.0-analysis-run-status-history.md b/CHANGELOG.d/0.81.0-analysis-run-status-history.md new file mode 100644 index 00000000..9fa4b870 --- /dev/null +++ b/CHANGELOG.d/0.81.0-analysis-run-status-history.md @@ -0,0 +1,5 @@ +# 0.81.0 analysis-run status history + +Detail of `GET /api/analysis-runs/{id}` shows the labeled append-only +lifecycle. The list stays latest-status only. Hidden runs 404. +Synthetic Demo Corp seed only. diff --git a/CHANGELOG.md b/CHANGELOG.md index e93ec6ef..4c17115a 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.81.0] - 2026-08-16 + +### Added + +- Analysis-run detail shows the labeled lifecycle: Pending, Running, + then Succeeded, with occurrence times from `analysis_run_status_event`. + The list stays latest-status only. Hidden runs still 404 and never + leak events. Failure codes stay machine tokens -- no invented label. + Synthetic Demo Corp seed only. + ## [0.80.0] - 2026-08-16 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 86b75ef8..b9a09fc0 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -96,6 +96,35 @@ async def _counts_by_run( return grouped +async def _status_history( + conn: asyncpg.Connection, + analysis_run_id: str, +) -> list[dict[str, Any]]: + """Labeled append-only lifecycle for one already-visible run.""" + rows = await conn.fetch( + """ + select status_ordinal, status_code, occurred_at, failure_code + from analysis_run_status_event + where analysis_run_id = $1::uuid + order by status_ordinal + """, + analysis_run_id, + ) + labels = await labels_for_codes(conn, [row["status_code"] for row in rows]) + history: list[dict[str, Any]] = [] + for row in rows: + item: dict[str, Any] = { + "status_ordinal": int(row["status_ordinal"]), + "status_code": row["status_code"], + "status_label": labels.get(row["status_code"], row["status_code"]), + "occurred_at": _iso(row["occurred_at"]), + } + if row["failure_code"]: + item["failure_code"] = row["failure_code"] + history.append(item) + return history + + async def _serialize_runs( conn: asyncpg.Connection, rows: list[asyncpg.Record], @@ -187,4 +216,5 @@ async def fetch_visible_analysis_run( detail["code_revision_sha"] = row["code_revision_sha"] if row["failure_code"]: detail["failure_code"] = row["failure_code"] + detail["status_history"] = await _status_history(conn, analysis_run_id) return detail diff --git a/backend/app/main.py b/backend/app/main.py index 81630d35..e039a2f5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1179,7 +1179,10 @@ async def read_analysis_run( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """One authorized analysis-run projection, or 404 when hidden.""" + """One authorized analysis-run projection, or 404 when hidden. + + Detail adds the labeled status history. Hidden runs never leak events. + """ _require_post_read(account) try: UUID(analysis_run_id) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3a6bab60..3104dadd 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -453,14 +453,29 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( dumped = str(visible) assert "postgresql://" not in dumped assert "select " not in dumped.lower() + assert "status_history" not in visible detail = client.get( f"/api/analysis-runs/{seeded_db['visible_run_id']}", headers={"Authorization": f"Bearer {demo_analyst_token}"}, ) assert detail.status_code == 200 - assert detail.json()["configuration_schema_version"] == "lineage-run-v1" - assert "snapshot_sha256" not in detail.json() + body = detail.json() + assert body["configuration_schema_version"] == "lineage-run-v1" + assert "snapshot_sha256" not in body + history = body["status_history"] + assert [event["status_label"] for event in history] == [ + "Pending", + "Running", + "Succeeded", + ] + assert [event["occurred_at"][:16] for event in history] == [ + "2026-01-12T12:31", + "2026-01-12T12:32", + "2026-01-12T12:33", + ] + assert all("failure_code" not in event for event in history) + assert "postgresql://" not in str(body) hidden = client.get( f"/api/analysis-runs/{seeded_db['hidden_run_id']}", diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 0621614d..c0f32bee 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -28,6 +28,9 @@ LineageWeave owns a fail-closed read projection of the #89 registry: - The payload carries lookup labels and non-negative aggregate counts. It does not carry source SQL, DSNs, raw records, image bytes, provider payloads, credentials, or another service's table names. +- `GET /api/analysis-runs/{id}` also returns the append-only labeled + `status_history`. The list does not. A failed event may include the + stored machine `failure_code`; this slice does not invent a label. - TEPP remains a versioned `AnalysisRunRequest` consumer (`lineageweave.tepp_client`). This slice does not fork TEPP arithmetic. - contextual-orchestrator remains the only LLM path. This slice does not @@ -37,8 +40,9 @@ LineageWeave owns a fail-closed read projection of the #89 registry: `make seed` writes one synthetic Demo Corp lineage run so the existing React home page can show Analysis runs without a second application. -Write/rebuild APIs, TEPP submission, and an Analysis Run Console remain -later slices. +The detail now shows the legal lifecycle the registry already stored. +Write/rebuild APIs, TEPP submission, and a fuller Analysis Run Console +remain later slices. ## References diff --git a/frontend/package.json b/frontend/package.json index ca3a1810..aacb6f74 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.80.0", + "version": "0.81.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index ba1285c4..a06e0dc3 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -189,6 +189,26 @@ describe("App, authenticated", () => { count_value: 3, }, ], + status_history: [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:31:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:32:00Z", + }, + { + status_ordinal: 3, + status_code: "analysis_status_succeeded", + status_label: "Succeeded", + occurred_at: "2026-01-12T12:33:00Z", + }, + ], }), ); } @@ -1365,6 +1385,10 @@ describe("App, authenticated", () => { expect(await screen.findByRole("heading", { name: "Lineage reconstruction · Succeeded · Demo Corp" })).toBeInTheDocument(); expect(screen.getByText(/Cutoff 2026-01-12/)).toBeInTheDocument(); expect(screen.getByText(/Requested 2026-01-12/)).toBeInTheDocument(); + const history = screen.getByRole("list", { name: "Analysis run status history" }); + expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); + expect(history).toHaveTextContent("Running 2026-01-12 12:32"); + expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8568e9ac..49b2bf16 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1432,6 +1432,16 @@ function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { ))}
+ {selected.status_history && selected.status_history.length > 0 && ( +
    + {selected.status_history.map((event) => ( +
  1. + {event.status_label} {event.occurred_at.slice(0, 16).replace("T", " ")} + {event.failure_code ? ` · ${event.failure_code}` : ""} +
  2. + ))} +
+ )} )} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bc1c39e6..5dea72c0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -499,6 +499,14 @@ export interface AnalysisRunCount { count_value: number; } +export interface AnalysisRunStatusEvent { + status_ordinal: number; + status_code: string; + status_label: string; + occurred_at: string; + failure_code?: string; +} + export interface AnalysisRun { analysis_run_id: string; run_kind_code: string; @@ -511,6 +519,7 @@ export interface AnalysisRun { knowledge_cutoff: string; requested_at: string; source_counts: AnalysisRunCount[]; + status_history?: AnalysisRunStatusEvent[]; } export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 7b561a3a..5603c60f 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.80.0" +__version__ = "0.81.0" diff --git a/pyproject.toml b/pyproject.toml index 57a3973a..3862da9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.80.0" +version = "0.81.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } From 91d5a056261fc626db25829aa7098d57e9a9ba5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:09:38 +0900 Subject: [PATCH 102/161] feat: open visible posts from an analysis-run detail (v0.82.0) (#103) Buyer gap: after #102 the run detail showed history but no way to open a post. Detail now lists ABAC-visible titles in the run's scope. Other-corp private posts stay hidden. List payloads stay aggregates-only. Synthetic titles only. --- ARCHITECTURE.md | 3 +- .../0.82.0-analysis-run-post-clickthrough.md | 4 ++ CHANGELOG.md | 9 +++ backend/app/analysis_run_ingestion.py | 57 +++++++++++++++++++ backend/tests/test_api.py | 4 ++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 5 ++ frontend/src/App.tsx | 25 +++++++- frontend/src/api.ts | 1 + lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 12 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 33d9d2c0..3f3a7cc9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -462,7 +462,8 @@ read of the #89 registry. `GET /api/analysis-runs` and in SQL: the requester always sees their own run; a corporate-entity or process-unit scope is visible only to affiliated accounts; a thread-group scope is visible only when the account can already see a -post in that group; `all_visible` is requester-only. Hidden runs 404. +post in that group; `all_visible` is requester-only. Hidden runs 404. Detail also lists ABAC-visible post titles in the +run's scope so a buyer can open a post without seeing hidden rows. The home list is clickable: `GET /api/analysis-runs/{id}` fills a labeled detail (cutoff, requested date, counts, status history) without exposing a DSN or raw record. Status history is detail-only diff --git a/CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md b/CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md new file mode 100644 index 00000000..1802a564 --- /dev/null +++ b/CHANGELOG.d/0.82.0-analysis-run-post-clickthrough.md @@ -0,0 +1,4 @@ +# 0.82.0 analysis-run post click-through + +Detail lists ABAC-visible post titles in the run scope. Hidden +other-corp private posts never appear. Synthetic titles only. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c17115a..8df6fa5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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.82.0] - 2026-08-16 + +### Added + +- Analysis-run detail lists ABAC-visible posts in the run's scope. + After `make seed`, the Demo Corp lineage run opens the Demo public + post. Hidden other-corp private posts never appear. List payloads + stay aggregates-only. + ## [0.81.0] - 2026-08-16 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index b9a09fc0..c18ddf32 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -53,6 +53,8 @@ run.code_revision_sha, scope.scope_kind_code, scope.corporate_entity_id, + scope.process_unit_id, + scope.scope_key, corp.entity_name as scope_entity_name, status.status_code, status.failure_code @@ -217,4 +219,59 @@ async def fetch_visible_analysis_run( if row["failure_code"]: detail["failure_code"] = row["failure_code"] detail["status_history"] = await _status_history(conn, analysis_run_id) + detail["visible_posts"] = await fetch_visible_scope_posts( + conn, + row["scope_kind_code"], + row["corporate_entity_id"], + row["process_unit_id"], + row["scope_key"], + affiliated_entity_ids, + ) return detail + + +async def fetch_visible_scope_posts( + conn: asyncpg.Connection, + scope_kind_code: str, + corporate_entity_id: Any, + process_unit_id: Any, + scope_key: str | None, + affiliated_entity_ids: list[str], +) -> list[dict[str, str]]: + """ABAC-visible post titles in the run's scope -- never a hidden body.""" + if scope_kind_code == "analysis_scope_corporate_entity" and corporate_entity_id: + rows = await conn.fetch( + "select post_id, post_title, visibility_code, corporate_entity_id " + "from source_post where corporate_entity_id = $1 " + "order by created_at, post_title", + corporate_entity_id, + ) + elif scope_kind_code == "analysis_scope_process_unit" and process_unit_id: + rows = await conn.fetch( + "select post_id, post_title, visibility_code, corporate_entity_id " + "from source_post where process_unit_id = $1 " + "order by created_at, post_title", + process_unit_id, + ) + elif scope_kind_code == "analysis_scope_thread_group" and scope_key: + rows = await conn.fetch( + "select post_id, post_title, visibility_code, corporate_entity_id " + "from source_post where thread_group_key = $1 " + "order by created_at, post_title", + scope_key, + ) + elif scope_kind_code == "analysis_scope_all_visible": + rows = await conn.fetch( + "select post_id, post_title, visibility_code, corporate_entity_id " + "from source_post order by created_at, post_title" + ) + else: + return [] + affiliated = {str(entity_id) for entity_id in affiliated_entity_ids} + posts: list[dict[str, str]] = [] + for row in rows: + visible = row["visibility_code"] == "public" or str(row["corporate_entity_id"]) in affiliated + if not visible: + continue + posts.append({"post_id": str(row["post_id"]), "post_title": row["post_title"]}) + return posts diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3104dadd..cfc2a555 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -475,7 +475,11 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( "2026-01-12T12:33", ] assert all("failure_code" not in event for event in history) + titles = {post["post_title"] for post in body["visible_posts"]} + assert "Own-corp private post" in titles + assert "Other-corp private post" not in titles assert "postgresql://" not in str(body) + assert "visible_posts" not in visible hidden = client.get( f"/api/analysis-runs/{seeded_db['hidden_run_id']}", diff --git a/frontend/package.json b/frontend/package.json index aacb6f74..5d3f2e2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.81.0", + "version": "0.82.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a06e0dc3..4fb1b564 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -189,6 +189,7 @@ describe("App, authenticated", () => { count_value: 3, }, ], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], status_history: [ { status_ordinal: 1, @@ -1389,7 +1390,11 @@ describe("App, authenticated", () => { expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); expect(history).toHaveTextContent("Running 2026-01-12 12:32"); expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); + expect(screen.getByRole("button", { name: "Open run post: Public post" })).toBeInTheDocument(); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Open run post: Public post" })); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); }); it("shows the calibrated period-report mean theta on the home page", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49b2bf16..1f350928 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1353,7 +1353,13 @@ function analysisRunCaption(run: AnalysisRun): string { .join(" · "); } -function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { +function AnalysisRunsPanel({ + accessToken, + onSelectPost, +}: { + accessToken: string; + onSelectPost: (postId: string) => void; +}) { const [runs, setRuns] = useState(null); const [selected, setSelected] = useState(null); const [error, setError] = useState(null); @@ -1442,6 +1448,21 @@ function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { ))} )} + {selected.visible_posts && selected.visible_posts.length > 0 && ( +
    + {selected.visible_posts.map((post) => ( +
  • + +
  • + ))} +
+ )} )} @@ -1736,7 +1757,7 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> - +
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5dea72c0..3dacb054 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -520,6 +520,7 @@ export interface AnalysisRun { requested_at: string; source_counts: AnalysisRunCount[]; status_history?: AnalysisRunStatusEvent[]; + visible_posts?: { post_id: string; post_title: string }[]; } export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5603c60f..b1f0c97b 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.81.0" +__version__ = "0.82.0" diff --git a/pyproject.toml b/pyproject.toml index 3862da9a..9238d87a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.81.0" +version = "0.82.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index 2c009d7e..f3307cb3 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.80.0" +version = "0.82.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From fdab35e74845b22ef4249efd5438a28b73c40402 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:19:40 +0000 Subject: [PATCH 103/161] docs(adr): keep registry ADR 0013 after #74 reused the number PR #91 landed an adaptive-orchestration ADR 0013 on the #74 base after this slice already used 0013 for the normalized analysis-run registry. Renumber the adaptive record to 0015 so ADR numbers stay unique. Co-authored-by: Seongho Bae --- ...ault.md => 0015-adaptive-contextual-orchestrator-default.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/adr/{0013-adaptive-contextual-orchestrator-default.md => 0015-adaptive-contextual-orchestrator-default.md} (96%) diff --git a/docs/adr/0013-adaptive-contextual-orchestrator-default.md b/docs/adr/0015-adaptive-contextual-orchestrator-default.md similarity index 96% rename from docs/adr/0013-adaptive-contextual-orchestrator-default.md rename to docs/adr/0015-adaptive-contextual-orchestrator-default.md index ee040207..433432fb 100644 --- a/docs/adr/0013-adaptive-contextual-orchestrator-default.md +++ b/docs/adr/0015-adaptive-contextual-orchestrator-default.md @@ -1,4 +1,4 @@ -# ADR-0013: Adaptive contextual-orchestrator mode is the default +# ADR-0015: Adaptive contextual-orchestrator mode is the default - Status: Accepted - Date: 2026-08-16 From 955d0b068d6f18a3697a6ddfa18a1da690ea2204 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:43:46 +0000 Subject: [PATCH 104/161] docs(changelog): point adaptive-orchestration note at ADR 0015 The #74 changelog fold still called that decision ADR 0013. This stack keeps the analysis-run registry as ADR 0013, so the adaptive record is 0015. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df6fa5c..6ac2df4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,7 @@ All notable changes to this project are documented here. Format follows and LLM-as-a-Judge consumers now request contextual-orchestrator `auto` mode so the orchestration plane can meet the quality requirement and then minimize known execution cost. Explicit checked `verify` paths remain unchanged - (ADR 0013). + (ADR 0015). ## [0.77.0] - 2026-08-14 From 88a1a0f8b925206083884f5350f7bbab41433fa7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:14:14 +0000 Subject: [PATCH 105/161] fix: keep Keyman evidence and honor analysis-run cutoff (v0.83.0) Migration 0016 no longer deletes overlapping Keyman mention_context. Analysis-run detail lists only posts known at knowledge_cutoff. Keyman org enrichment finishes before the write transaction. Replace remaining real organization names with synthetic AGP examples. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 4 +- ...-analysis-run-cutoff-and-keyman-upgrade.md | 2 + CHANGELOG.md | 16 +++ backend/app/analysis_run_ingestion.py | 18 ++- backend/app/entity_relationship_ingestion.py | 5 +- backend/app/keyman_ingestion.py | 73 ++++++------ backend/app/main.py | 40 +++---- backend/tests/test_api.py | 25 ++++- ...08-organization-abbreviation-resolution.md | 4 +- docs/adr/0009-cross-post-actor-identity.md | 6 +- .../0010-corporate-hierarchy-auto-creation.md | 20 ++-- ...016-analysis-run-knowledge-cutoff-posts.md | 50 +++++++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 4 +- docs/ontology/lineageweave-kg.ttl | 4 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 1 + frontend/src/App.tsx | 2 +- lineageweave/__init__.py | 2 +- lineageweave/organization_name_resolution.py | 6 +- .../0015_organization_name_resolution.sql | 4 +- migrations/0016_cross_post_actor_identity.sql | 16 ++- pyproject.toml | 2 +- scripts/seed_demo_data.py | 13 ++- tests/test_ingestion_transaction_contracts.py | 106 ++++++++++++++++++ tests/test_organization_name_resolution.py | 26 ++--- tests/test_person_mention_projection.py | 91 ++++++++++++--- uv.lock | 2 +- 27 files changed, 412 insertions(+), 132 deletions(-) create mode 100644 CHANGELOG.d/0.83.0-analysis-run-cutoff-and-keyman-upgrade.md create mode 100644 docs/adr/0016-analysis-run-knowledge-cutoff-posts.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3f3a7cc9..bedeba28 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -463,7 +463,9 @@ in SQL: the requester always sees their own run; a corporate-entity or process-unit scope is visible only to affiliated accounts; a thread-group scope is visible only when the account can already see a post in that group; `all_visible` is requester-only. Hidden runs 404. Detail also lists ABAC-visible post titles in the -run's scope so a buyer can open a post without seeing hidden rows. +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. The home list is clickable: `GET /api/analysis-runs/{id}` fills a labeled detail (cutoff, requested date, counts, status history) without exposing a DSN or raw record. Status history is detail-only diff --git a/CHANGELOG.d/0.83.0-analysis-run-cutoff-and-keyman-upgrade.md b/CHANGELOG.d/0.83.0-analysis-run-cutoff-and-keyman-upgrade.md new file mode 100644 index 00000000..f2de7cfa --- /dev/null +++ b/CHANGELOG.d/0.83.0-analysis-run-cutoff-and-keyman-upgrade.md @@ -0,0 +1,2 @@ +Analysis-run detail applies knowledge_cutoff to visible posts. Migration +0016 no longer deletes overlapping Keyman mention_context. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac2df4e..d0edb5d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ 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.83.0] - 2026-08-16 + +### Fixed + +- Analysis-run detail now lists only ABAC-visible posts whose + `created_at` is at or before that run's `knowledge_cutoff`. After + `make seed`, open the Demo Corp lineage run: Demo public post is + there; a later own-corp follow-up is not. The live post list is + unchanged. Click a listed title to inspect what that cutoff + reconstructed (ADR 0016). +- Upgrading through `0016_cross_post_actor_identity.sql` copies R&R + person names into `post_summary_person_mention` and leaves Keyman + `post_person_mention.mention_context` in place. Re-run Keyman only + when you want a new Keyman set -- a later summary no longer erases + the stolen row. + ## [0.82.0] - 2026-08-16 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index c18ddf32..e96c2b7c 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -226,6 +226,7 @@ async def fetch_visible_analysis_run( row["process_unit_id"], row["scope_key"], affiliated_entity_ids, + row["knowledge_cutoff"], ) return detail @@ -237,33 +238,46 @@ async def fetch_visible_scope_posts( process_unit_id: Any, scope_key: str | None, affiliated_entity_ids: list[str], + knowledge_cutoff: Any, ) -> list[dict[str, str]]: - """ABAC-visible post titles in the run's scope -- never a hidden body.""" + """ABAC-visible post titles known at the run cutoff -- never a hidden body. + + ``knowledge_cutoff`` is the analysis clock (W3C Time / ISO 8601-1:2019; + ADR 0013/0016). A later live post must not appear inside an earlier run. + """ if scope_kind_code == "analysis_scope_corporate_entity" and corporate_entity_id: rows = await conn.fetch( "select post_id, post_title, visibility_code, corporate_entity_id " "from source_post where corporate_entity_id = $1 " + "and created_at <= $2 " "order by created_at, post_title", corporate_entity_id, + knowledge_cutoff, ) elif scope_kind_code == "analysis_scope_process_unit" and process_unit_id: rows = await conn.fetch( "select post_id, post_title, visibility_code, corporate_entity_id " "from source_post where process_unit_id = $1 " + "and created_at <= $2 " "order by created_at, post_title", process_unit_id, + knowledge_cutoff, ) elif scope_kind_code == "analysis_scope_thread_group" and scope_key: rows = await conn.fetch( "select post_id, post_title, visibility_code, corporate_entity_id " "from source_post where thread_group_key = $1 " + "and created_at <= $2 " "order by created_at, post_title", scope_key, + knowledge_cutoff, ) elif scope_kind_code == "analysis_scope_all_visible": rows = await conn.fetch( "select post_id, post_title, visibility_code, corporate_entity_id " - "from source_post order by created_at, post_title" + "from source_post where created_at <= $1 " + "order by created_at, post_title", + knowledge_cutoff, ) else: return [] diff --git a/backend/app/entity_relationship_ingestion.py b/backend/app/entity_relationship_ingestion.py index 30fd6236..091e58f4 100644 --- a/backend/app/entity_relationship_ingestion.py +++ b/backend/app/entity_relationship_ingestion.py @@ -6,6 +6,7 @@ from __future__ import annotations +import asyncio from collections.abc import Mapping, Sequence from typing import Any @@ -38,7 +39,9 @@ async def ingest_post_entity_relationships( if not organization_names: return [] - relationships = client.classify(post_title, post_body, organization_names) + relationships = await asyncio.to_thread( + client.classify, post_title, post_body, organization_names + ) for relationship in relationships: await conn.execute( diff --git a/backend/app/keyman_ingestion.py b/backend/app/keyman_ingestion.py index e477b3a1..906442ba 100644 --- a/backend/app/keyman_ingestion.py +++ b/backend/app/keyman_ingestion.py @@ -185,9 +185,13 @@ async def ingest_post_keymen( real ones get the exact same behavior as before ADR 0008/0010 (raw affiliation names, unresolved). - The post's prior Keyman mention set is replaced atomically after a successful - extraction. ``persist_graph=False`` lets a larger caller defer graph - reconciliation until the end of its own transaction. + Organization resolution and hierarchy creation finish before the Keyman + write transaction. Callers must not wrap this function in an outer + transaction: that would turn ``pg_advisory_xact_lock`` into a savepoint + and hold the creation lock across later LLM work. The post's prior + Keyman mention set is replaced atomically after enrichment. + ``persist_graph=False`` lets a larger caller persist edges in its own + short write transaction after this function returns. Raises whatever `client.extract` raises (e.g. a `NullKeymanExtractionClient` would raise `RuntimeError`) -- callers should check `client.available` @@ -198,20 +202,9 @@ async def ingest_post_keymen( hierarchy_inference_client = hierarchy_inference_client or NullCorporateHierarchyInferenceClient() mentions = await asyncio.to_thread(client.extract, post_title, post_body) candidates = await _load_corporate_entity_candidates(conn) - normalized_mentions: list[PersonMention] = [] - await conn.execute( - "delete from post_person_mention where post_id = $1", post_id - ) - + resolved_by_mention: list[tuple[PersonMention, list[tuple[str, str, str | None]]]] = [] for mention in mentions: - person_id = await _upsert_person(conn, mention) - await conn.execute( - "insert into post_person_mention (post_id, person_id) values ($1, $2) on conflict do nothing", - post_id, - person_id, - ) - - resolved_names: list[str] = [] + resolved_orgs: list[tuple[str, str, str | None]] = [] for organization_name in mention.affiliated_organization_names: resolved_name = await resolve_organization_name( conn, @@ -228,24 +221,40 @@ async def ingest_post_keymen( verification_client, candidates, ) - await _upsert_affiliation( - conn, + resolved_orgs.append((organization_name, resolved_name, corporate_entity_id)) + resolved_by_mention.append((mention, resolved_orgs)) + + normalized_mentions: list[PersonMention] = [] + async with conn.transaction(): + await conn.execute( + "delete from post_person_mention where post_id = $1", post_id + ) + for mention, resolved_orgs in resolved_by_mention: + person_id = await _upsert_person(conn, mention) + await conn.execute( + "insert into post_person_mention (post_id, person_id) values ($1, $2) on conflict do nothing", + post_id, person_id, - organization_name, - resolved_name, - corporate_entity_id, - mention.job_title, ) - if resolved_name not in resolved_names: - resolved_names.append(resolved_name) - normalized_mentions.append( - replace( - mention, - affiliated_organization_names=tuple(resolved_names), + resolved_names: list[str] = [] + for organization_name, resolved_name, corporate_entity_id in resolved_orgs: + await _upsert_affiliation( + conn, + person_id, + organization_name, + resolved_name, + corporate_entity_id, + mention.job_title, + ) + if resolved_name not in resolved_names: + resolved_names.append(resolved_name) + normalized_mentions.append( + replace( + mention, + affiliated_organization_names=tuple(resolved_names), + ) ) - ) - - if persist_graph: - await persist_edges_for_post(conn, post_id) + if persist_graph: + await persist_edges_for_post(conn, post_id) return normalized_mentions diff --git a/backend/app/main.py b/backend/app/main.py index e039a2f5..e77b173b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -588,27 +588,27 @@ async def extract_post_keymen( # literal text either blows the token budget or is silently # ignored (see lineageweave/post_content_normalization.py). post_body = normalize_post_body(raw_body, vision_client=_vision_client()).text + mentions = await ingest_post_keymen( + conn, + keyman_client, + post_id, + post["post_title"], + post_body, + resolution_client=_organization_name_resolution_client(), + verification_client=_relation_verification_client(), + hierarchy_inference_client=_corporate_hierarchy_inference_client(), + persist_graph=False, + ) + organization_names = sorted( + {name for mention in mentions for name in mention.affiliated_organization_names} + ) + # relationship_client is gated by the same settings check as + # keyman_client above (both read ORCHESTRATOR_BASE_URL/_API_KEY), + # so reaching here means it is available too. + relationships = await ingest_post_entity_relationships( + conn, relationship_client, post_id, post["post_title"], post_body, organization_names + ) async with conn.transaction(): - mentions = await ingest_post_keymen( - conn, - keyman_client, - post_id, - post["post_title"], - post_body, - resolution_client=_organization_name_resolution_client(), - verification_client=_relation_verification_client(), - hierarchy_inference_client=_corporate_hierarchy_inference_client(), - persist_graph=False, - ) - organization_names = sorted( - {name for mention in mentions for name in mention.affiliated_organization_names} - ) - # relationship_client is gated by the same settings check as - # keyman_client above (both read ORCHESTRATOR_BASE_URL/_API_KEY), - # so reaching here means it is available too. - relationships = await ingest_post_entity_relationships( - conn, relationship_client, post_id, post["post_title"], post_body, organization_names - ) await persist_edges_for_post(conn, post_id) return { "post_id": str(post["post_id"]), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index cfc2a555..df1dfb4a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -296,11 +296,17 @@ def _seed_analysis_run( (account_id, role_id), ) - def _insert_post(title: str, corporate_entity_id, visibility_code: str, body: str = "body") -> str: + def _insert_post( + title: str, + corporate_entity_id, + visibility_code: str, + body: str = "body", + created_at: str = "2026-01-10T12:00:00Z", + ) -> str: cur.execute( - "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) " - "values (%s, %s, %s, %s, 'voc', %s) returning post_id", - (account_id, corporate_entity_id, title, body, visibility_code), + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, created_at) " + "values (%s, %s, %s, %s, 'voc', %s, %s) returning post_id", + (account_id, corporate_entity_id, title, body, visibility_code, created_at), ) return str(cur.fetchone()[0]) @@ -313,6 +319,13 @@ def _insert_post(title: str, corporate_entity_id, visibility_code: str, body: st "The weather in Gwangju was irrelevant.", ) other_private_post_id = _insert_post("Other-corp private post", other_corp_id, "private") + late_own_private_post_id = _insert_post( + "Late own-corp private post", + own_corp_id, + "private", + "A follow-up written after the January 2026 run cutoff.", + created_at="2026-01-20T12:00:00Z", + ) cur.execute( "insert into cataloged_person (person_name, person_side_code) values " @@ -398,6 +411,7 @@ def _insert_post(title: str, corporate_entity_id, visibility_code: str, body: st "own_corp_id": str(own_corp_id), "other_corp_id": str(other_corp_id), "own_private_post_id": own_private_post_id, + "late_own_private_post_id": late_own_private_post_id, "other_private_post_id": other_private_post_id, "our_person_id": our_person_id, "counterpart_person_id": counterpart_person_id, @@ -477,6 +491,7 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( assert all("failure_code" not in event for event in history) titles = {post["post_title"] for post in body["visible_posts"]} assert "Own-corp private post" in titles + assert "Late own-corp private post" not in titles assert "Other-corp private post" not in titles assert "postgresql://" not in str(body) assert "visible_posts" not in visible @@ -503,7 +518,7 @@ def test_post_list_includes_public_and_own_corp_but_excludes_other_corp(client, response = client.get("/api/posts", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 titles = {post["post_title"] for post in response.json()} - assert titles == {"Public post", "Own-corp private post"} + assert titles == {"Public post", "Own-corp private post", "Late own-corp private post"} public = next(post for post in response.json() if post["post_title"] == "Public post") assert public["voc_type_label"] == "Voice of Customer" assert public["visibility_label"] == "Public" diff --git a/docs/adr/0008-organization-abbreviation-resolution.md b/docs/adr/0008-organization-abbreviation-resolution.md index 0ade7d36..72b12125 100644 --- a/docs/adr/0008-organization-abbreviation-resolution.md +++ b/docs/adr/0008-organization-abbreviation-resolution.md @@ -7,8 +7,8 @@ Real post text names organizations by abbreviated or slang forms a human reader immediately recognizes but a string-matching pipeline -cannot -- e.g. "AGP," a common Korean contraction of "Aurora Grid Power" -(Korea Hydro & Nuclear Power). `lineageweave.corporate_hierarchy_resolution` +cannot -- e.g. "AGP," a synthetic contraction of "Aurora Grid Power". +`lineageweave.corporate_hierarchy_resolution` already resolves near-matches (a trailing legal suffix, a minor abbreviation) via character-sequence similarity (`difflib.SequenceMatcher`, grounded in Bhattacharya & Getoor, 2007's diff --git a/docs/adr/0009-cross-post-actor-identity.md b/docs/adr/0009-cross-post-actor-identity.md index c577d998..7bdbfa09 100644 --- a/docs/adr/0009-cross-post-actor-identity.md +++ b/docs/adr/0009-cross-post-actor-identity.md @@ -54,7 +54,11 @@ Person evidence sources remain separate: Keyman extraction replaces `post_summary_person_mention`. `combined_post_person_mention` is a read-only union used for lineage and KG derivation. This prevents a new summary from deleting Keyman evidence and prevents removed R&R actors -from surviving as stale Keymen. +from surviving as stale Keymen. Migration 0016 copies matching R&R +actor names into `post_summary_person_mention` and must not delete +overlapping Keyman rows -- `mention_context` has no R&R column, and a +later summary replacement would otherwise erase the only remaining +person evidence. Each resolved actor gets a real Knowledge Graph mention edge (new diff --git a/docs/adr/0010-corporate-hierarchy-auto-creation.md b/docs/adr/0010-corporate-hierarchy-auto-creation.md index 84fc23c8..d034fe9b 100644 --- a/docs/adr/0010-corporate-hierarchy-auto-creation.md +++ b/docs/adr/0010-corporate-hierarchy-auto-creation.md @@ -9,18 +9,14 @@ matching only ever locates an *already-cataloged* `corporate_entity` row -- it has no path to create one. This was fine while the only `corporate_entity` catalog was synthetic demo fixtures with a handful -of names extraction would naturally already know. Real Milestone 2 data -exposed the actual gap: `corporate_entity` for the unseen dataset holds -only the employer's own 2-row hierarchy (its own group/subsidiary -structure); every counterparty, customer, partner, or competitor -organization named in real posts is, by definition, something outside -that hierarchy. A direct count confirmed the consequence: **0 of 4,154 -`person_affiliation` rows and 0 of 9,852 R&R organization-actor -mentions ever resolved to a real `corporate_entity`** -- the standing -"통합 고객사 계열 tree AI" (integrated customer affiliate tree) -requirement, present in this product's brief since Milestone 1 -(the Samsung -> Samsung Electronics Korea -> ... example), was never -actually populated for real extraction, silently. +of names extraction would naturally already know. A synthetic batch +where the catalog holds only the employer's own two-row hierarchy +exposes the same gap: every counterparty named in a post is, by +definition, outside that catalog. Similarity matching then resolves +**0 affiliation rows and 0 R&R organization-actor mentions** -- the +standing integrated customer-affiliate tree requirement (Harbor Group +-> Harbor Devices Korea -> ... in the synthetic brief) stays empty +until a verified creation path exists. ## Decision diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md new file mode 100644 index 00000000..d6ac70db --- /dev/null +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -0,0 +1,50 @@ +# ADR 0016 — Analysis-run visible posts honor the run knowledge cutoff + +**Decision status:** Accepted +**Date:** 2026-08-16 + +## Context + +ADR 0013 stores `analysis_run.knowledge_cutoff` as the analysis clock: +what that run was allowed to know. The registry trigger already refuses +a cutoff earlier than `analysis_source_snapshot.maximum_available_time`. +The home-page detail, however, listed every ABAC-visible title in the +run's scope from live `source_post` rows. Fixture and seed posts that +defaulted to `created_at = now()` therefore appeared inside a January +2026 run, including a later own-corp follow-up the buyer would treat as +part of that reconstruction. + +W3C Time Ontology in OWL (Hobbs & Pan, 2017) and ISO 8601-1:2019 keep +distinct clocks from collapsing. A knowledge cutoff is not "posts the +account can see today." + +## Decision + +`fetch_visible_scope_posts` filters `created_at <= knowledge_cutoff` on +every scope branch (corporate entity, process unit, thread group, and +all-visible). ABAC visibility is applied after that temporal gate. +Click-through still opens the live post body -- post versioning is a +later slice -- but the run list itself must not advertise a post the +run was not allowed to know. + +Seed and API fixtures backdate in-cutoff posts. A late own-corp private +post remains on the live post list and stays out of the January 2026 +run. + +## Consequences + +- After `make seed`, the Demo Corp lineage run lists Demo public post + and other in-cutoff Demo Corp titles. The later fixture account-review + post (2026-02-10) does not appear. +- Open the run, then open a listed post, to inspect what that cutoff + actually reconstructed. +- Post-body versioning at the cutoff remains future work. + +## References + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +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 b439cb9c..a1dc7395 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -8,7 +8,7 @@ | Source | Product implication | Implemented evidence | |---|---|---| | W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. | -| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. | +| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). | | 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. | @@ -68,7 +68,7 @@ provenance, retention, and immutable evidence rather than blanket masking. | Claim | Falsifiable test | |---|---| | One snapshot supports multiple analyses | Insert two runs over one snapshot with different valid cutoffs. | -| Future evidence is excluded | Reject a run whose cutoff precedes the snapshot's maximum availability time. | +| Future evidence is excluded | Reject a run whose cutoff precedes the snapshot's maximum availability time. A late own-corp post stays out of `visible_posts`. | | Evidence cannot change after derivation | Reject snapshot/count updates and count insert/delete after the first run. | | Count/run race is serialized | Both paths acquire the snapshot row first; a later concurrency test must prove one legal winner and no lost freeze. | | Request identity is stable | Reject analysis-run updates; scope and lifecycle live in their own relations. | diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 8a41a40e..04e8d1b6 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -237,8 +237,8 @@ ################################################################# # organization_name_resolution (raw/canonical organization-name pairs) # -# ADR 0008: an abbreviated/slang organization mention (e.g. "한수원") -# is resolved to its full canonical name ("한국수력원자력") and +# ADR 0008: an abbreviated/slang organization mention (e.g. "AGP") +# is resolved to its full canonical name ("Aurora Grid Power") and # cross-verified via external search before being trusted. This is not # a new KG node/edge type -- no new :lookupCode term is declared here, # since organization_name_resolution's columns are not a diff --git a/frontend/package.json b/frontend/package.json index 5d3f2e2b..d1e24268 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.82.0", + "version": "0.83.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 4fb1b564..a3da5de6 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1390,6 +1390,7 @@ describe("App, authenticated", () => { expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); expect(history).toHaveTextContent("Running 2026-01-12 12:32"); expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); + expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Open run post: Public post" })).toBeInTheDocument(); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1f350928..f866bb30 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1449,7 +1449,7 @@ function AnalysisRunsPanel({ )} {selected.visible_posts && selected.visible_posts.length > 0 && ( -
    +
      {selected.visible_posts.map((post) => (
    • ); @@ -1448,20 +1494,25 @@ function AnalysisRunsPanel({ ))} )} - {selected.visible_posts && selected.visible_posts.length > 0 && ( -
        - {selected.visible_posts.map((post) => ( -
      • - -
      • - ))} -
      + {selected.visible_posts && selected.visible_posts.length > 0 ? ( + <> + {corpusHint &&

      {corpusHint}

      } +
        + {selected.visible_posts.map((post) => ( +
      • + +
      • + ))} +
      + + ) : ( +

      {analysisRunEmptyPostsHint(selected)}

      )}
)} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5bd7638d..e89edfd0 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.83.0" +__version__ = "0.84.0" diff --git a/pyproject.toml b/pyproject.toml index f7b33f6c..ed229d42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.83.0" +version = "0.84.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index cb7c74f8..f6f575cc 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -19,6 +19,7 @@ from __future__ import annotations import argparse +import hashlib import os import sys from pathlib import Path @@ -30,6 +31,7 @@ import psycopg2 from lineageweave.http_client import get_json_list, post_form +from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable REALM = "lineageweave-demo" DEFAULT_POSTGRES_DSN = "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave" @@ -37,6 +39,12 @@ DEFAULT_KEYCLOAK_ADMIN_USER = os.environ.get("KEYCLOAK_ADMIN", "admin") DEFAULT_VALKEY_URL = "redis://localhost:16379/0" +# ADR 0013: one Demo Corp capture, many runs (lineage + TEPP). +DEMO_SOURCE_SNAPSHOT_MATERIAL = b"lineageweave-synthetic-demo-snapshot-v1" +DEMO_SOURCE_CONTRACT_VERSION = "demo-source-contract-v1" +DEMO_LINEAGE_IDEMPOTENCY_KEY = "demo-lineage-seed-2026-w02" +DEMO_TEPP_IDEMPOTENCY_KEY = "demo-tepp-seed-2026-w02" + # (post_title, ticket_title, due_date) -- Event Lineage fixtures a report # member click opens. Activity seed uses the same titles so Valkey matches. FIXTURE_TICKET_SPECS = ( @@ -333,6 +341,11 @@ def seed( account_ids["demo.analyst"], corporate_entity_id, ) + _seed_demo_tepp_run( + cur, + account_ids["demo.analyst"], + corporate_entity_id, + ) conn.commit() finally: @@ -1203,36 +1216,57 @@ def _seed_demo_period_report(cur, author_account_id, corporate_entity_id, proces _persist_seed_period_report(cur, "process_unit", high_key, w03, week3[high_key]) -def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None: - """Insert one Demo-Corp lineage run so Analysis runs is not empty. +def demo_source_snapshot_sha256() -> str: + """Return the reusable Demo Corp snapshot digest (never a source row).""" + return hashlib.sha256(DEMO_SOURCE_SNAPSHOT_MATERIAL).hexdigest() - Aggregates only: three synthetic documents, one thread. The digest is - a hash of a fixed demo contract string -- never a source row or DSN. - """ - import hashlib - digest = hashlib.sha256(b"lineageweave-synthetic-demo-snapshot-v1").hexdigest() +def _ensure_demo_source_snapshot(cur): + """Return the shared Demo Corp capture, inserting it on first seed. + + Lineage and TEPP runs share this snapshot (ADR 0013: one capture, + many runs). The digest is a hash of a fixed demo contract string -- + never a source row or DSN. + """ + digest = demo_source_snapshot_sha256() cur.execute( "select analysis_source_snapshot_id from analysis_source_snapshot " "where snapshot_sha256 = %s", (digest,), ) snapshot_row = cur.fetchone() - if snapshot_row is None: - cur.execute( - """ - insert into analysis_source_snapshot - (snapshot_sha256, source_contract_version, - maximum_available_time, captured_at) - values (%s, 'demo-source-contract-v1', - '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') - returning analysis_source_snapshot_id - """, - (digest,), - ) - snapshot_id = cur.fetchone()[0] - else: - snapshot_id = snapshot_row[0] + if snapshot_row is not None: + return snapshot_row[0] + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, %s, + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + (digest, DEMO_SOURCE_CONTRACT_VERSION), + ) + return cur.fetchone()[0] + + +def _ensure_demo_source_counts(cur, snapshot_id) -> None: + """Insert demo counts only when the snapshot still has none. + + ``enforce_analysis_source_count_freeze`` runs BEFORE INSERT. After + the first run points at the snapshot, a later ``INSERT ... ON + CONFLICT DO NOTHING`` still raises ``analysis_source_count_frozen_after_run`` + and rolls back the whole ``seed()`` transaction. Skip when counts + already exist so ``make seed`` can be re-run. + """ + cur.execute( + "select 1 from analysis_source_count " + "where analysis_source_snapshot_id = %s limit 1", + (snapshot_id,), + ) + if cur.fetchone() is not None: + return cur.execute( """ insert into analysis_source_count @@ -1242,17 +1276,27 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - (%s, 'analysis_count_thread', 1), (%s, 'analysis_count_lineage_node', 5), (%s, 'analysis_count_lineage_edge', 4) - on conflict do nothing """, (snapshot_id, snapshot_id, snapshot_id, snapshot_id), ) + + +def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None: + """Insert one Demo-Corp lineage run so Analysis runs is not empty. + + Aggregates only: three synthetic documents, one thread. Reuses the + shared Demo Corp snapshot so a later TEPP run can attach to the + same capture. + """ + snapshot_id = _ensure_demo_source_snapshot(cur) + _ensure_demo_source_counts(cur, snapshot_id) cur.execute( """ select analysis_run_id from analysis_run where requested_by_account_id = %s - and idempotency_key = 'demo-lineage-seed-2026-w02' + and idempotency_key = %s """, - (requested_by_account_id,), + (requested_by_account_id, DEMO_LINEAGE_IDEMPOTENCY_KEY), ) run_row = cur.fetchone() if run_row is None: @@ -1263,12 +1307,18 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_lineage', 'demo-lineage-seed-2026-w02', + values (%s, 'analysis_run_lineage', %s, %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, '2026-01-12T12:30:00Z') returning analysis_run_id """, - (snapshot_id, requested_by_account_id, "b" * 64, "c" * 40), + ( + snapshot_id, + DEMO_LINEAGE_IDEMPOTENCY_KEY, + requested_by_account_id, + "b" * 64, + "c" * 40, + ), ) run_id = cur.fetchone()[0] else: @@ -1298,6 +1348,103 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - ) +def tepp_seed_request() -> AnalysisRunRequest: + """Build the Demo Corp TEPP request against the shared snapshot digest.""" + return AnalysisRunRequest( + idempotency_key=DEMO_TEPP_IDEMPOTENCY_KEY, + tenant_workspace_id="demo-workspace", + snapshot_id=demo_source_snapshot_sha256(), + knowledge_cutoff="2026-01-12T12:00:00Z", + model_contract_version="tepp-analysis-run-v1", + output_profile="calibrated_event_measurement", + ) + + +def tepp_seed_outcome(client: TeppClient | None = None) -> tuple[str, str | None]: + """Ask TEPP through the published client. A missing transport is Failed. + + Never invents a psychometric score. ``tepp_not_available`` means the + channel was dropped, not a calibrated negative result. A live + envelope is also not a persistable measurement in this seed, so the + run is not stamped Succeeded. + """ + request = tepp_seed_request() + try: + (client or TeppClient()).submit_analysis_run(request) + except TeppNotAvailable: + return "analysis_status_failed", "tepp_not_available" + return "analysis_status_failed", "tepp_result_not_persisted" + + +def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> None: + """Insert one Demo-Corp TEPP run so the kind is visible without a live TEPP. + + Uses :func:`tepp_seed_outcome` against the shared lineage snapshot. + Default transport is unavailable, so the run ends Failed / + ``tepp_not_available`` -- never a fake theta. + """ + snapshot_id = _ensure_demo_source_snapshot(cur) + _ensure_demo_source_counts(cur, snapshot_id) + cur.execute( + """ + select analysis_run_id from analysis_run + where requested_by_account_id = %s + and idempotency_key = %s + """, + (requested_by_account_id, DEMO_TEPP_IDEMPOTENCY_KEY), + ) + run_row = cur.fetchone() + if run_row is None: + cur.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, 'analysis_run_tepp', %s, + %s, '2026-01-12T12:00:00Z', 'tepp-run-v1', %s, %s, + '2026-01-12T12:34:00Z') + returning analysis_run_id + """, + ( + snapshot_id, + DEMO_TEPP_IDEMPOTENCY_KEY, + requested_by_account_id, + "d" * 64, + "e" * 40, + ), + ) + run_id = cur.fetchone()[0] + else: + run_id = run_row[0] + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, corporate_entity_id) + values (%s, 'analysis_scope_corporate_entity', %s) + on conflict (analysis_run_id) do nothing + """, + (run_id, corporate_entity_id), + ) + final_status, failure_code = tepp_seed_outcome() + events = [ + (1, "analysis_status_pending", "2026-01-12T12:35:00Z", None), + (2, "analysis_status_running", "2026-01-12T12:36:00Z", None), + (3, final_status, "2026-01-12T12:37:00Z", failure_code), + ] + for ordinal, status, occurred, fail in events: + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at, failure_code) + values (%s, %s, %s, %s, %s) + on conflict do nothing + """, + (run_id, ordinal, status, occurred, fail), + ) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--postgres-dsn", default=DEFAULT_POSTGRES_DSN) diff --git a/tests/test_seed_tepp_run.py b/tests/test_seed_tepp_run.py new file mode 100644 index 00000000..c865c475 --- /dev/null +++ b/tests/test_seed_tepp_run.py @@ -0,0 +1,85 @@ +"""Seeded TEPP analysis runs go through tepp_client, never a local model.""" + +from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable +from scripts.seed_demo_data import ( + _ensure_demo_source_counts, + demo_source_snapshot_sha256, + tepp_seed_outcome, + tepp_seed_request, +) + + +class _RecordingUnavailableClient(TeppClient): + """Default-path stand-in that records the request then drops the channel.""" + + def __init__(self) -> None: + super().__init__() + self.submitted: list[AnalysisRunRequest] = [] + + def submit_analysis_run(self, request: AnalysisRunRequest) -> dict[str, object]: + self.submitted.append(request) + raise TeppNotAvailable("TEPP has no live HTTP endpoint yet.") + + +class _AcceptingClient(TeppClient): + """Transport that returns an envelope without a persistable measurement.""" + + def __init__(self) -> None: + super().__init__(transport=lambda _payload: {"status": "accepted"}) + + +class _CountCursor: + """Minimal cursor for proving re-seed skips a frozen count insert.""" + + def __init__(self, existing_counts: bool) -> None: + self.existing_counts = existing_counts + self.statements: list[str] = [] + + def execute(self, sql: str, _params=None) -> None: + self.statements.append(" ".join(sql.split())) + + def fetchone(self): + if self.existing_counts and "from analysis_source_count" in self.statements[-1]: + return (1,) + return None + + +def test_tepp_seed_request_targets_the_shared_demo_snapshot() -> None: + request = tepp_seed_request() + assert request.snapshot_id == demo_source_snapshot_sha256() + assert request.idempotency_key == "demo-tepp-seed-2026-w02" + assert request.model_contract_version == "tepp-analysis-run-v1" + assert request.output_profile == "calibrated_event_measurement" + + +def test_tepp_seed_outcome_calls_client_and_does_not_invent_a_score() -> None: + client = _RecordingUnavailableClient() + status, failure = tepp_seed_outcome(client) + assert status == "analysis_status_failed" + assert failure == "tepp_not_available" + assert client.submitted == [tepp_seed_request()] + + +def test_tepp_seed_outcome_default_client_is_unavailable_not_a_fake_score() -> None: + status, failure = tepp_seed_outcome() + assert status == "analysis_status_failed" + assert failure == "tepp_not_available" + + +def test_tepp_seed_outcome_does_not_treat_an_empty_envelope_as_success() -> None: + status, failure = tepp_seed_outcome(_AcceptingClient()) + assert status == "analysis_status_failed" + assert failure == "tepp_result_not_persisted" + + +def test_ensure_demo_source_counts_skips_insert_when_counts_exist() -> None: + cursor = _CountCursor(existing_counts=True) + _ensure_demo_source_counts(cursor, "snapshot-1") + assert any("from analysis_source_count" in sql for sql in cursor.statements) + assert not any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements) + + +def test_ensure_demo_source_counts_inserts_when_the_snapshot_is_empty() -> None: + cursor = _CountCursor(existing_counts=False) + _ensure_demo_source_counts(cursor, "snapshot-1") + assert any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements) diff --git a/uv.lock b/uv.lock index 06408c2a..411243f5 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.83.0" +version = "0.84.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From dea0b3afe9faca9071fbccb8940c530ef0d01a4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:39:15 +0900 Subject: [PATCH 107/161] fix(ui): show analysis-run digest prefixes on detail (#121) The #89 review asked for 12-character code and config prefixes so an operator can match the approved revision. Full digests stay on the API only. Do not merge until this review item is checked. Co-authored-by: Cursor Agent Co-authored-by: Seongho Bae --- frontend/src/App.test.tsx | 10 ++++++++++ frontend/src/App.tsx | 13 +++++++++++++ frontend/src/api.ts | 2 ++ 3 files changed, 25 insertions(+) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e2a30c68..65763b7f 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -235,6 +235,9 @@ describe("App, authenticated", () => { }, ], visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + code_revision_sha: "abcdef0123456789deadbeefcafebabe", + configuration_sha256: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", status_history: [ { status_ordinal: 1, @@ -1454,6 +1457,13 @@ describe("App, authenticated", () => { expect(await screen.findByRole("heading", { name: "Lineage reconstruction · Succeeded · Demo Corp" })).toBeInTheDocument(); expect(screen.getByText(/Cutoff 2026-01-12/)).toBeInTheDocument(); expect(screen.getByText(/Requested 2026-01-12/)).toBeInTheDocument(); + const digests = screen.getByLabelText("Analysis run reproducibility digests"); + expect(digests).toHaveTextContent("Code abcdef012345"); + expect(digests).toHaveTextContent("Config 0123456789ab"); + expect(digests).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe"); + expect(digests).not.toHaveTextContent( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); const history = screen.getByRole("list", { name: "Analysis run status history" }); expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); expect(history).toHaveTextContent("Running 2026-01-12 12:32"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 50602c68..9d947ee4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1477,6 +1477,19 @@ function AnalysisRunsPanel({ {" · "} Requested {selected.requested_at.slice(0, 10)}

+ {(selected.code_revision_sha || selected.configuration_sha256) && ( +

+ {selected.code_revision_sha + ? `Code ${selected.code_revision_sha.slice(0, 12)}` + : ""} + {selected.code_revision_sha && selected.configuration_sha256 + ? " · " + : ""} + {selected.configuration_sha256 + ? `Config ${selected.configuration_sha256.slice(0, 12)}` + : ""} +

+ )}
    {selected.source_counts.map((count) => (
  • diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3dacb054..740529cb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -521,6 +521,8 @@ export interface AnalysisRun { source_counts: AnalysisRunCount[]; status_history?: AnalysisRunStatusEvent[]; visible_posts?: { post_id: string; post_title: string }[]; + code_revision_sha?: string; + configuration_sha256?: string; } export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { From e9c42babc051b8fc48e411b2cdf4ed919d776d00 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:49:31 +0900 Subject: [PATCH 108/161] fix: keep failed-run next actions kind-specific (#124) * feat: seed a TEPP analysis run through tepp_client (v0.84.0) Buyer gap: home Analysis runs only showed lineage reconstruction. make seed now records a Demo Corp TEPP measurement via tepp_client. The default transport is unavailable, so the row is Failed / tepp_not_available -- never a fabricated theta. TEPP stays a wire client, not a local psychometric engine. * fix: fail-closed TEPP seed on the shared Demo Corp snapshot #111 still marked a live unused envelope Succeeded, named a different capture than the registry row, and re-inserted frozen counts. Seed now reuses the lineage snapshot (ADR 0013), skips count inserts after the first run, and keeps missing or unused TEPP Failed. The home list tells the operator to open the run and connect TEPP; detail history keeps tepp_not_available. Co-authored-by: Seongho Bae * fix: keep failed-run next actions kind-specific A failed lineage row must not tell the operator to connect TEPP. Stacked PRs now run the same GitHub Checks as PRs to main. Co-authored-by: Seongho Bae * docs: keep TEPP next-action copy off failed lineage rows Co-authored-by: Seongho Bae * fix: keep TEPP corpus hint off a succeeded measurement A calibrated TEPP row must not tell the operator to replace Failed. Co-authored-by: Seongho Bae --------- Co-authored-by: Seongho Bae Co-authored-by: Cursor Agent Co-authored-by: Seongho Bae --- .github/workflows/tests.yml | 1 - ARCHITECTURE.md | 5 +- CHANGELOG.d/0.84.0-tepp-analysis-run.md | 6 +- CHANGELOG.md | 4 +- CLAUDE.md | 4 +- docs/adr/0014-authorized-analysis-run-read.md | 9 +-- frontend/src/App.test.tsx | 63 ++++++++++++++++--- frontend/src/App.tsx | 29 ++++++--- 8 files changed, 91 insertions(+), 30 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 36e24332..e78d3625 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] permissions: contents: read diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b662b00b..2492ee50 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -475,9 +475,10 @@ labeled detail (cutoff, requested date, counts, status history) without exposing a DSN or raw record. Status history is detail-only and uses lookup labels plus occurrence times; a failure event keeps its machine `failure_code` rather than an invented caption. Failed -list rows add a next-action line (open the run, then connect the +TEPP list rows add a next-action line (open the run, then connect the measurement service) so `tepp_not_available` is not mistaken for a -calibrated negative result. The +calibrated negative result. A failed lineage row tells the operator +to retry reconstruction, not to connect TEPP. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · diff --git a/CHANGELOG.d/0.84.0-tepp-analysis-run.md b/CHANGELOG.d/0.84.0-tepp-analysis-run.md index 080cc824..c9653189 100644 --- a/CHANGELOG.d/0.84.0-tepp-analysis-run.md +++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md @@ -1,6 +1,6 @@ # 0.84.0 TEPP analysis-run seed Seed writes `analysis_run_tepp` via `tepp_client` on the shared Demo -Corp snapshot. The home list shows Failed and the next action; detail -history keeps `tepp_not_available`. Missing transport is not a fake -measurement. +Corp snapshot. The home list shows Failed and a kind-specific next +action; detail history keeps `tepp_not_available`. Missing transport +is not a fake measurement. A failed lineage row does not mention TEPP. diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f4878b..c36b2666 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ All notable changes to this project are documented here. Format follows keeps `tepp_not_available` -- never a fabricated theta. TEPP stays a wire client, not a local psychometric engine. `make seed` skips snapshot-count inserts once counts exist so a re-run does not hit - the freeze trigger. + the freeze trigger. A failed lineage row tells the operator to retry + reconstruction; only a failed TEPP row mentions the measurement + service. Stacked PRs now run the same GitHub Checks as PRs to main. ## [0.83.0] - 2026-08-16 diff --git a/CLAUDE.md b/CLAUDE.md index 3af72ad4..0a2950e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,4 +11,6 @@ transport or an unused accepted envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`). Do not invent a theta or a local psychometric substitute. The home list caption stays `kind · status · entity`; the machine failure code is detail-only -(ADR 0014). Open the Failed row, then connect a live TEPP transport. +(ADR 0014). Open a Failed TEPP row, then connect a live TEPP +transport. A failed lineage row retries reconstruction -- it does not +mention TEPP. diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index dea201bf..841dfb38 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -43,10 +43,11 @@ run on the same snapshot so the existing React home page can show both kinds without a second application. The TEPP run is Failed / `tepp_not_available` when the default transport is missing -- the list keeps that machine code off the caption (this decision) and instead -tells the operator to open the run, then connect the measurement -service. The detail now shows the legal lifecycle the registry already -stored. Write/rebuild APIs, a live TEPP transport, and a fuller -Analysis Run Console remain later slices. +tells the operator to open the TEPP run, then connect the measurement +service. A failed lineage row tells the operator to retry +reconstruction, not to connect TEPP. The detail now shows the legal +lifecycle the registry already stored. Write/rebuild APIs, a live TEPP +transport, and a fuller Analysis Run Console remain later slices. ## References diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 65763b7f..d8d82c8d 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -59,6 +59,8 @@ describe("App, authenticated", () => { chatUnavailable?: boolean; searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; + failedLineageRun?: boolean; + succeededTeppRun?: boolean; }) { const statusLabel: Record = { open: "Open", @@ -178,8 +180,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -205,10 +209,14 @@ describe("App, authenticated", () => { }, { status_ordinal: 3, - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", occurred_at: "2026-01-12T12:37:00Z", - failure_code: "tepp_not_available", + ...(options?.succeededTeppRun + ? {} + : { failure_code: "tepp_not_available" }), }, ], }), @@ -272,8 +280,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_succeeded", - status_label: "Succeeded", + status_code: options?.failedLineageRun + ? "analysis_status_failed" + : "analysis_status_succeeded", + status_label: options?.failedLineageRun ? "Failed" : "Succeeded", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:30:00Z", source_counts: [ @@ -291,8 +301,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -1489,6 +1501,39 @@ describe("App, authenticated", () => { expect(teppHistory).not.toHaveTextContent("Succeeded"); }); + it("does not tell a failed lineage run to connect the measurement service", async () => { + stubBackend({ failedLineageRun: true }); + render(); + + const list = await screen.findByRole("list", { name: "Analysis runs" }); + expect(list).toHaveTextContent("Lineage reconstruction · Failed · Demo Corp"); + expect(list).toHaveTextContent( + "Open this run to see why it failed, then retry reconstruction from a current snapshot.", + ); + expect(list).toHaveTextContent( + "Open this run to see why it failed, then connect the measurement service and re-run.", + ); + const lineageButton = screen.getByRole("button", { + name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", + }); + expect(lineageButton).not.toHaveTextContent("measurement service"); + }); + + it("does not tell a succeeded TEPP run to replace Failed", async () => { + stubBackend({ succeededTeppRun: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Succeeded · Demo Corp", + }), + ); + expect( + await screen.findByText("These posts are the cutoff corpus this TEPP run measured."), + ).toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); + }); + 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 9d947ee4..65af9596 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1356,14 +1356,22 @@ function analysisRunCaption(run: AnalysisRun): string { /** * Next action for a failed run on the home list. * - * The machine `failure_code` stays on detail history (ADR 0014). The - * list tells the operator to open the run, then reconnect the service. + * The machine `failure_code` stays on detail history (ADR 0014). Copy + * is kind-specific so a failed lineage reconstruction is not mistaken + * for a missing TEPP transport. */ function analysisRunNextAction(run: AnalysisRun): string | null { - if (run.status_code === "analysis_status_failed") { - return "Open this run to see why it failed, then connect the measurement service and re-run."; + if (run.status_code !== "analysis_status_failed") { + return null; + } + switch (run.run_kind_code) { + 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."; + default: + return "Open this run to see why it failed, then retry after the blocking service is connected."; } - return null; } /** @@ -1389,10 +1397,13 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string { */ function analysisRunCorpusHint(run: AnalysisRun): string | null { if (run.run_kind_code !== "analysis_run_tepp") return null; - return ( - "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + - "transport, then re-run, to replace Failed with a calibrated result." - ); + if (run.status_code === "analysis_status_failed") { + return ( + "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + + "transport, then re-run, to replace Failed with a calibrated result." + ); + } + return "These posts are the cutoff corpus this TEPP run measured."; } function AnalysisRunsPanel({ From 44912a642f997830620718b2e69106457e73c3f3 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:54:37 +0000 Subject: [PATCH 109/161] fix(ui): keep analysis-run digests audible and warn on live posts (v0.84.1) (#127) * fix(ui): keep analysis-run digests audible and warn on live posts aria-label on the digest paragraph hid the prefixes from assistive technology. Move the label to a group, keep prefixes as visible text, and put the full digest on hover. Tell the operator that a cutoff title opens the live body so they compare it with the run clock. Co-authored-by: Seongho Bae * docs: mark analysis-run seed pointer as v0.84.1 Co-authored-by: Seongho Bae --------- Co-authored-by: Cursor Agent Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 6 +- .../0.84.1-analysis-run-digest-a11y.md | 5 ++ CHANGELOG.md | 13 ++++ CLAUDE.md | 5 +- ...016-analysis-run-knowledge-cutoff-posts.md | 20 ++++- .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 6 +- frontend/package.json | 2 +- frontend/src/App.css | 21 ++++- frontend/src/App.test.tsx | 31 +++++++- frontend/src/App.tsx | 76 +++++++++++++++---- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 13 files changed, 162 insertions(+), 29 deletions(-) create mode 100644 CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2492ee50..063b7a19 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -471,8 +471,10 @@ revision and configuration digest prefixes. `tepp_client` on that same snapshot; the default transport is unavailable, so that run is Failed rather than a fabricated score. The home list is clickable: `GET /api/analysis-runs/{id}` fills a -labeled detail (cutoff, requested date, counts, status history) -without exposing a DSN or raw record. Status history is detail-only +labeled detail (cutoff, requested date, 12-character digest prefixes +with full digests on hover, counts, status history) +without exposing a DSN or raw record. Opening a cutoff title warns +that the live body may have changed after the run. Status history is detail-only and uses lookup labels plus occurrence times; a failure event keeps its machine `failure_code` rather than an invented caption. Failed TEPP list rows add a next-action line (open the run, then connect the diff --git a/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md b/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md new file mode 100644 index 00000000..213eb545 --- /dev/null +++ b/CHANGELOG.d/0.84.1-analysis-run-digest-a11y.md @@ -0,0 +1,5 @@ +# 0.84.1 Analysis-run digest a11y and live-body warning + +Detail prefixes stay audible and hoverable. Open a cutoff title only +after reading that the live body may have changed since the run. +The list stays aggregates-only. diff --git a/CHANGELOG.md b/CHANGELOG.md index c36b2666..22b372c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.84.1] - 2026-08-16 + +### Fixed + +- Analysis-run detail keeps 12-character digest prefixes as visible + text (so assistive technology hears `Code` / `Config` values) and + puts the full digest on hover. Open the Demo Corp lineage run, hover + a prefix, and match it to the API payload. The home list still hides + digests even when the list JSON includes them. +- Opening a cutoff title now says the live body may have changed after + that run. Compare the opened post with the cutoff date before you + treat it as reconstructed evidence (ADR 0016). + ## [0.84.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 0a2950e9..71e67103 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.84.0) +## Analysis-run seed (v0.84.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 @@ -14,3 +14,6 @@ theta or a local psychometric substitute. The home list caption stays (ADR 0014). Open a Failed TEPP row, then connect a live TEPP transport. A failed lineage row retries reconstruction -- it does not mention TEPP. +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). diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index d6ac70db..f9c82a86 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -25,7 +25,15 @@ every scope branch (corporate entity, process unit, thread group, and all-visible). ABAC visibility is applied after that temporal gate. Click-through still opens the live post body -- post versioning is a later slice -- but the run list itself must not advertise a post the -run was not allowed to know. +run was not allowed to know. The detail must say that next action +plainly: compare the opened body with this cutoff before treating it +as reconstructed evidence. + +Reproducibility digests on the same detail use a labeled group whose +accessible name does not replace the visible prefixes (W3C Accessible +Name and Description Computation 1.1). Full digests stay on `title` +for hover verification and on the API payload; the home list stays +aggregates-only. Seed and API fixtures backdate in-cutoff posts. A late own-corp private post remains on the live post list and stays out of the January 2026 @@ -36,8 +44,10 @@ run. - After `make seed`, the Demo Corp lineage run lists Demo public post and other in-cutoff Demo Corp titles. The later fixture account-review post (2026-02-10) does not appear. -- Open the run, then open a listed post, to inspect what that cutoff - actually reconstructed. +- Open the run, read the live-body warning, then open a listed post + and compare it with the cutoff date. +- Hover a digest prefix to read the full code or configuration digest + when you need to match the API payload. - Post-body versioning at the cutoff remains future work. ## References @@ -48,3 +58,7 @@ rules* (confirmed 2024; Amendment 1:2022). World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). https://www.w3.org/TR/owl-time/ + +World Wide Web Consortium. (2018). *Accessible name and description +computation 1.1* (W3C Recommendation). +https://www.w3.org/TR/accname-1.1/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index a1dc7395..b41b31c1 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -8,7 +8,8 @@ | Source | Product implication | Implemented evidence | |---|---|---| | W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. | -| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). | +| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Opening a listed title warns that the live body may have changed after that cutoff. | +| W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. | | ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | | PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. | | 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. | @@ -98,5 +99,8 @@ PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C Recommendation). https://www.w3.org/TR/prov-o/ +World Wide Web Consortium. (2018). *Accessible name and description +computation 1.1* (W3C Recommendation). https://www.w3.org/TR/accname-1.1/ + World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/frontend/package.json b/frontend/package.json index c21ed209..8ce5f334 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.84.0", + "version": "0.84.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index dfd0f2e8..8f38b4dd 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -85,9 +85,26 @@ cursor: pointer; } +:root { + --lw-opacity-meta: 0.7; + --lw-font-size-meta: 0.85rem; +} + .post-meta { - opacity: 0.7; - font-size: 0.85rem; + opacity: var(--lw-opacity-meta); + font-size: var(--lw-font-size-meta); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; } .post-body { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index d8d82c8d..c77d965b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -293,6 +293,9 @@ describe("App, authenticated", () => { count_value: 3, }, ], + code_revision_sha: "abcdef0123456789deadbeefcafebabe", + configuration_sha256: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", }, { analysis_run_id: "run-demo-tepp", @@ -1460,6 +1463,12 @@ describe("App, authenticated", () => { expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); expect(list).not.toHaveTextContent("select "); + expect(list).not.toHaveTextContent("Code abcdef012345"); + expect(list).not.toHaveTextContent("Config 0123456789ab"); + expect(list).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe"); + expect(list).not.toHaveTextContent( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); await userEvent.click( screen.getByRole("button", { @@ -1470,21 +1479,39 @@ describe("App, authenticated", () => { expect(screen.getByText(/Cutoff 2026-01-12/)).toBeInTheDocument(); expect(screen.getByText(/Requested 2026-01-12/)).toBeInTheDocument(); const digests = screen.getByLabelText("Analysis run reproducibility digests"); + expect(digests).toHaveTextContent("Hover a prefix to read the full digest for verification."); expect(digests).toHaveTextContent("Code abcdef012345"); expect(digests).toHaveTextContent("Config 0123456789ab"); expect(digests).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe"); expect(digests).not.toHaveTextContent( "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ); + expect(screen.getByTitle("abcdef0123456789deadbeefcafebabe")).toHaveTextContent("Code abcdef012345"); + expect( + screen.getByTitle("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + ).toHaveTextContent("Config 0123456789ab"); const history = screen.getByRole("list", { name: "Analysis run status history" }); expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); expect(history).toHaveTextContent("Running 2026-01-12 12:32"); expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Open run post: Public post" })).toBeInTheDocument(); + expect( + screen.getByText( + "Opening a title shows the live post. Compare it with cutoff 2026-01-12 before you treat the body as reconstructed evidence — it may have changed after this run.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Open live post (may have changed after cutoff): Public post", + }), + ).toBeInTheDocument(); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Open run post: Public post" })); + await userEvent.click( + screen.getByRole("button", { + name: "Open live post (may have changed after cutoff): Public post", + }), + ); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); await userEvent.click( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 65af9596..94803543 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1406,6 +1406,62 @@ function analysisRunCorpusHint(run: AnalysisRun): string | null { return "These posts are the cutoff corpus this TEPP run measured."; } +/** Git-style prefix. The full digest stays on `title` for verification. */ +const ANALYSIS_RUN_DIGEST_PREFIX_LENGTH = 12; + +function analysisRunDigestPrefix(digest: string): string { + return digest.slice(0, ANALYSIS_RUN_DIGEST_PREFIX_LENGTH); +} + +/** + * Next action when a cutoff title opens the live post (ADR 0016). + * + * Post-body versioning is a later slice. Until then the operator must + * compare the opened body with this run's cutoff instead of treating + * today's text as reconstructed evidence. + */ +function analysisRunLivePostWarning(cutoffIso: string): string { + const cutoffDate = cutoffIso.slice(0, 10); + return ( + `Opening a title shows the live post. Compare it with cutoff ${cutoffDate} ` + + "before you treat the body as reconstructed evidence — it may have changed after this run." + ); +} + +function analysisRunLivePostButtonLabel(postTitle: string): string { + return `Open live post (may have changed after cutoff): ${postTitle}`; +} + +function AnalysisRunReproducibilityDigests({ + codeRevisionSha, + configurationSha256, +}: { + codeRevisionSha?: string; + configurationSha256?: string; +}) { + if (!codeRevisionSha && !configurationSha256) { + return null; + } + return ( +
    +

    + + Hover a prefix to read the full digest for verification.{" "} + + {codeRevisionSha ? ( + {`Code ${analysisRunDigestPrefix(codeRevisionSha)}`} + ) : null} + {codeRevisionSha && configurationSha256 ? " · " : null} + {configurationSha256 ? ( + + {`Config ${analysisRunDigestPrefix(configurationSha256)}`} + + ) : null} +

    +
    + ); +} + function AnalysisRunsPanel({ accessToken, onSelectPost, @@ -1488,19 +1544,10 @@ function AnalysisRunsPanel({ {" · "} Requested {selected.requested_at.slice(0, 10)}

    - {(selected.code_revision_sha || selected.configuration_sha256) && ( -

    - {selected.code_revision_sha - ? `Code ${selected.code_revision_sha.slice(0, 12)}` - : ""} - {selected.code_revision_sha && selected.configuration_sha256 - ? " · " - : ""} - {selected.configuration_sha256 - ? `Config ${selected.configuration_sha256.slice(0, 12)}` - : ""} -

    - )} +
      {selected.source_counts.map((count) => (
    • @@ -1521,12 +1568,13 @@ function AnalysisRunsPanel({ {selected.visible_posts && selected.visible_posts.length > 0 ? ( <> {corpusHint &&

      {corpusHint}

      } +

      {analysisRunLivePostWarning(selected.knowledge_cutoff)}

        {selected.visible_posts.map((post) => (
      • {error &&

        {error}

        } {runs.length === 0 ? (

        - No analysis runs visible to this account yet -- try `make seed`. + No analysis runs visible to this account yet. Request a lineage + reconstruction, or ask an administrator to run make seed.

        ) : (
          diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 740529cb..e35bcf3e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -532,3 +532,21 @@ export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: export function fetchAnalysisRun(accessToken: string, analysisRunId: string): Promise { return backendFetch(`/api/analysis-runs/${analysisRunId}`, accessToken); } + +export interface CreateAnalysisRunRequest { + run_kind_code?: string; + scope_kind_code?: string; + corporate_entity_id?: string; + knowledge_cutoff?: string; + idempotency_key: string; +} + +export function createAnalysisRun( + accessToken: string, + request: CreateAnalysisRunRequest, +): Promise { + return backendFetch("/api/analysis-runs", accessToken, { + method: "POST", + body: JSON.stringify(request), + }); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 65fa5d18..5e05ef4f 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.84.1" +__version__ = "0.85.0" diff --git a/pyproject.toml b/pyproject.toml index b27655c2..8750ae3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.84.1" +version = "0.85.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_analysis_run_create.py b/tests/test_analysis_run_create.py new file mode 100644 index 00000000..4e24a422 --- /dev/null +++ b/tests/test_analysis_run_create.py @@ -0,0 +1,134 @@ +"""Authorized analysis-run create hashes the cutoff bag, never a score.""" + +from datetime import datetime, timezone + +from backend.app.analysis_run_ingestion import ( + AnalysisRunCreateError, + _resolve_corporate_entity_id, + plan_analysis_run_capture, +) +import pytest + + +_CUTOFF = datetime(2026, 1, 12, 12, 0, tzinfo=timezone.utc) +_EARLIER = datetime(2026, 1, 10, 9, 0, tzinfo=timezone.utc) + + +def test_capture_digest_is_stable_for_the_same_authorized_bag() -> None: + first = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=_CUTOFF, + idempotency_key="client-key-1", + post_ids=["post-b", "post-a"], + thread_keys=["thread-a", "thread-a"], + latest_post_created_at=_EARLIER, + ) + second = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=_CUTOFF, + idempotency_key="client-key-1", + post_ids=["post-a", "post-b"], + thread_keys=["thread-a", "thread-a"], + latest_post_created_at=_EARLIER, + ) + assert first.snapshot_sha256 == second.snapshot_sha256 + assert first.configuration_sha256 == second.configuration_sha256 + assert first.document_count == 2 + assert first.thread_count == 1 + assert first.maximum_available_time == _EARLIER + assert "theta" not in first.snapshot_sha256 + assert first.configuration_schema_version == "lineage-run-v1" + + +def test_later_cutoff_or_other_kind_does_not_reuse_the_wrong_digest() -> None: + lineage = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=_CUTOFF, + idempotency_key="client-key-1", + post_ids=["post-a"], + thread_keys=["thread-a"], + latest_post_created_at=_EARLIER, + ) + later = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=datetime(2026, 1, 13, 12, 0, tzinfo=timezone.utc), + idempotency_key="client-key-1", + post_ids=["post-a"], + thread_keys=["thread-a"], + latest_post_created_at=_EARLIER, + ) + tepp = plan_analysis_run_capture( + 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", + post_ids=["post-a"], + thread_keys=["thread-a"], + latest_post_created_at=_EARLIER, + ) + assert lineage.snapshot_sha256 != later.snapshot_sha256 + assert lineage.snapshot_sha256 == tepp.snapshot_sha256 + assert lineage.configuration_sha256 != tepp.configuration_sha256 + assert tepp.configuration_schema_version == "tepp-run-v1" + + +def test_omitted_cutoff_keeps_the_same_client_key_stable() -> None: + first = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=_CUTOFF, + idempotency_key="client-key-1", + post_ids=["post-a"], + thread_keys=["thread-a"], + latest_post_created_at=_EARLIER, + cutoff_explicit=False, + ) + later_clock = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=datetime(2026, 1, 13, 12, 0, tzinfo=timezone.utc), + idempotency_key="client-key-1", + post_ids=["post-a"], + thread_keys=["thread-a"], + latest_post_created_at=_EARLIER, + cutoff_explicit=False, + ) + assert first.configuration_sha256 == later_clock.configuration_sha256 + assert first.snapshot_sha256 == later_clock.snapshot_sha256 + + +def test_empty_corpus_uses_the_cutoff_as_latest_available_time() -> None: + capture = plan_analysis_run_capture( + run_kind_code="analysis_run_lineage", + scope_kind_code="analysis_scope_corporate_entity", + corporate_entity_id="corp-1", + knowledge_cutoff=_CUTOFF, + idempotency_key="client-key-1", + post_ids=[], + thread_keys=[], + latest_post_created_at=None, + ) + assert capture.document_count == 0 + assert capture.thread_count == 0 + assert capture.maximum_available_time == _CUTOFF + + +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"]) + assert hidden.value.status_code == 404 + with pytest.raises(AnalysisRunCreateError) as ambiguous: + _resolve_corporate_entity_id(None, ["corp-1", "corp-2"]) + assert ambiguous.value.status_code == 422 + assert _resolve_corporate_entity_id(None, ["corp-1"]) == "corp-1" diff --git a/tests/test_seed_tepp_run.py b/tests/test_seed_tepp_run.py index c865c475..b25908cb 100644 --- a/tests/test_seed_tepp_run.py +++ b/tests/test_seed_tepp_run.py @@ -3,6 +3,7 @@ from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable from scripts.seed_demo_data import ( _ensure_demo_source_counts, + _seed_demo_tepp_run, demo_source_snapshot_sha256, tepp_seed_outcome, tepp_seed_request, @@ -83,3 +84,49 @@ def test_ensure_demo_source_counts_inserts_when_the_snapshot_is_empty() -> None: cursor = _CountCursor(existing_counts=False) _ensure_demo_source_counts(cursor, "snapshot-1") assert any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements) + + +class _TeppSeedCursor: + """Drive `_seed_demo_tepp_run` without a live database.""" + + def __init__(self) -> None: + self.statements: list[str] = [] + self.params: list[object] = [] + + def execute(self, sql: str, params=None) -> None: + self.statements.append(" ".join(sql.split())) + self.params.append(params) + + def fetchone(self): + last = self.statements[-1] + if last.lstrip().startswith("select") and "from analysis_source_snapshot" in last: + return None + if "insert into analysis_source_snapshot" in last: + return ("snapshot-demo",) + if last.lstrip().startswith("select") and "from analysis_source_count" in last: + return None + if last.lstrip().startswith("select") and "from analysis_run" in last: + return None + if "insert into analysis_run" in last: + return ("run-demo-tepp",) + return None + + +def test_seed_demo_tepp_run_inserts_failed_tepp_not_available() -> None: + cursor = _TeppSeedCursor() + _seed_demo_tepp_run(cursor, "account-1", "corp-1") + run_inserts = [sql for sql in cursor.statements if "insert into analysis_run" in sql] + assert run_inserts, "seed must insert the TEPP analysis_run row" + assert any("analysis_run_tepp" in sql for sql in run_inserts) + status_params = [ + params + for sql, params in zip(cursor.statements, cursor.params, strict=True) + if "insert into analysis_run_status_event" in sql + ] + assert any( + params is not None and "analysis_status_failed" in params and "tepp_not_available" in params + for params in status_params + ) + assert not any( + params is not None and "analysis_status_succeeded" in params for params in status_params + ) diff --git a/uv.lock b/uv.lock index 156b0813..20f9a879 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.84.1" +version = "0.85.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 3c17fd3abbec94b6c2464bd6b369bba736f844b6 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:02:24 +0900 Subject: [PATCH 111/161] feat: walk team and organization related nodes (v0.86.0) (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related-node RWR now loads team and organization mention edges, so a team-only follow-up is no longer an island. R&R team names become buttons. Thread-group run lists honor knowledge_cutoff. ADR 0018 — #125 already used ADR 0017 for POST /api/analysis-runs. Co-authored-by: Cursor Agent Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 18 +- .../0.86.0-related-nodes-team-org-walk.md | 2 + CHANGELOG.md | 18 ++ backend/app/analysis_run_ingestion.py | 1 + backend/app/knowledge_graph.py | 142 +++++++++++- backend/app/main.py | 31 +++ backend/app/post_summary_ingestion.py | 52 ++++- backend/tests/test_api.py | 156 +++++++++++++ ...016-analysis-run-knowledge-cutoff-posts.md | 3 + docs/adr/0018-related-nodes-team-org-walk.md | 67 ++++++ .../RELATED_NODE_TEAM_ORG_REFERENCES.md | 19 ++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 58 +++++ frontend/src/App.tsx | 217 +++++++++++++----- frontend/src/api.ts | 17 +- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_person_mention_projection.py | 88 ++++++- uv.lock | 2 +- 19 files changed, 806 insertions(+), 91 deletions(-) create mode 100644 CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md create mode 100644 docs/adr/0018-related-nodes-team-org-walk.md create mode 100644 docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index da35cc77..aa557bd7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -256,11 +256,14 @@ pattern and then hide the action button so it cannot 503 again. `find_linked_post_ids` first expands to every post sharing a mentioned person before calling `backend/app/knowledge_graph.py::load_visible_subgraph` -- that function -only loads edges among an *already-known* post set (its other caller, -`related_for_person`, pre-resolves the full set itself), it does not -discover new posts on its own; a real bug from calling it with only the -single starting post was caught while building this and is now -regression-tested (`test_post_chat_cites_a_post_linked_only_via_a_shared_keyman`). +only loads edges among an *already-known* post set (its other callers, +`related_for_person` / `related_for_entity` / `related_for_team`, +pre-resolve the full set themselves), it does not discover new posts on +its own; a real bug from calling it with only the single starting post +was caught while building this and is now regression-tested +(`test_post_chat_cites_a_post_linked_only_via_a_shared_keyman`). +Person, team, and organization mention channels load independently +(ADR 0018): a team-only or organization-only post still walks. ### Frontend (`frontend/`) @@ -276,8 +279,9 @@ summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel affiliate tree (resolved ancestors plus unresolved org roots), Keyman + counterparty panels (a Keyman click loads RWR related nodes; a related corporate-entity node, a resolved Keyman affiliation, -or a classified name that resolves to a cataloged org continues -the same walk via `GET /api/corporate-entities/{id}/related`; +a classified name that resolves to a cataloged org, or an R&R team +continues the same walk via `GET /api/corporate-entities/{id}/related` +or `GET /api/teams/{id}/related`; `post_admin` can extract), and an in-popup chat whose cited sources open a sliding evidence panel (`EvidencePanel`, CSS diff --git a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md new file mode 100644 index 00000000..4efa8100 --- /dev/null +++ b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md @@ -0,0 +1,2 @@ +Related-node walks include team and organization mention edges. Click an +R&R team to open sibling posts. Thread-group run lists honor knowledge_cutoff. diff --git a/CHANGELOG.md b/CHANGELOG.md index fec2c509..434c4d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ 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.0] - 2026-08-16 + +### Added + +- Related-node walks now include team and organization mention edges. + After `make seed` and a summary that names 설계팀 on two posts, open + either post, click the R&R team, and open the sibling post (ADR 0018). + A team-only follow-up is no longer an island. +- `GET /api/teams/{team_id}/related` starts the same RWR walk Keyman + and corporate-entity related already use. Related team chips are + buttons. + +### Fixed + +- Thread-group analysis-run *lists* now require an in-cutoff visible + post. A later public post in that thread group no longer surfaces a + January run the account was not allowed to know. + ## [0.85.0] - 2026-08-16 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 6a144981..d26eb6f6 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -52,6 +52,7 @@ and exists ( select 1 from source_post p where p.thread_group_key = scope.scope_key + and p.created_at <= run.knowledge_cutoff and ( p.visibility_code = 'public' or p.corporate_entity_id = any($2::uuid[]) diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index 4035134c..ce7289bb 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -18,9 +18,13 @@ EDGE_AFFILIATION, EDGE_CO_MENTION, EDGE_MENTION, + EDGE_MENTION_ORGANIZATION, + EDGE_MENTION_TEAM, + EDGE_TEAM_AFFILIATION, NODE_CORPORATE_ENTITY, NODE_PERSON, NODE_POST, + NODE_TEAM, KnowledgeGraphEdgeSpec, adjacency_from_edges, knowledge_graph_edges_for_post, @@ -235,6 +239,16 @@ async def corporate_entity_exists(conn: asyncpg.Connection, entity_id: str) -> b return row is not None +async def team_exists(conn: asyncpg.Connection, team_id: str) -> bool: + """True when ``team_id`` is a UUID that exists in ``cataloged_team``.""" + try: + UUID(team_id) + except ValueError: + return False + row = await conn.fetchrow("select 1 from cataloged_team where team_id = $1", team_id) + return row is not None + + async def visible_mention_post_ids( conn: asyncpg.Connection, person_id: str, @@ -258,27 +272,57 @@ async def visible_affiliation_post_ids( entity_id: str, can_see_post, ) -> list[str]: - """Visible posts whose Keyman or R&R people affiliate with an entity.""" + """Visible posts that mention an entity via a person or a direct org mention.""" rows = await conn.fetch( """ select distinct post.post_id, post.visibility_code, post.corporate_entity_id, post.created_at - from person_affiliation affiliation - join combined_post_person_mention mention - on mention.person_id = affiliation.person_id - join source_post post on post.post_id = mention.post_id - where affiliation.affiliated_corporate_entity_id = $1 + from source_post post + where post.post_id in ( + select mention.post_id + from person_affiliation affiliation + join combined_post_person_mention mention + on mention.person_id = affiliation.person_id + where affiliation.affiliated_corporate_entity_id = $1 + union + select org_mention.post_id + from post_organization_mention org_mention + where org_mention.corporate_entity_id = $1 + ) order by post.created_at, post.post_id """, entity_id, ) return [str(row["post_id"]) for row in rows if can_see_post(row)] + +async def visible_team_mention_post_ids( + conn: asyncpg.Connection, + team_id: str, + can_see_post, +) -> list[str]: + """Visible post ids supported by a cataloged team mention.""" + rows = await conn.fetch( + """ + select post.post_id, post.visibility_code, post.corporate_entity_id + from post_team_mention mention + join source_post post on post.post_id = mention.post_id + where mention.team_id = $1 + order by post.created_at, post.post_id + """, + team_id, + ) + return [str(row["post_id"]) for row in rows if can_see_post(row)] + async def load_visible_subgraph( conn: asyncpg.Connection, visible_post_ids: list[str], ) -> list[KnowledgeGraphEdgeSpec]: - """Edges supported by at least one post the account may already see.""" + """Edges supported by at least one post the account may already see. + + Person, team, and organization mention channels are independent. A + team-only or organization-only post must still walk (ADR 0018). + """ if not visible_post_ids: return [] person_rows = await conn.fetch( @@ -287,7 +331,19 @@ async def load_visible_subgraph( visible_post_ids, ) person_ids = [row["person_id"] for row in person_rows] - if not person_ids: + team_rows = await conn.fetch( + "select distinct team_id from post_team_mention " + "where post_id = any($1::uuid[])", + visible_post_ids, + ) + team_ids = [row["team_id"] for row in team_rows] + organization_rows = await conn.fetch( + "select distinct corporate_entity_id from post_organization_mention " + "where post_id = any($1::uuid[])", + visible_post_ids, + ) + organization_ids = [row["corporate_entity_id"] for row in organization_rows] + if not person_ids and not team_ids and not organization_ids: return [] rows = await conn.fetch( """ @@ -326,6 +382,48 @@ async def load_visible_subgraph( and edge.target_node_id = any($2::uuid[])) ) ) + or ( + edge.edge_type_code = $8 + and ( + (edge.source_node_type_code = $4 + and edge.source_node_id = any($1::uuid[])) + or + (edge.target_node_type_code = $4 + and edge.target_node_id = any($1::uuid[])) + or + (edge.source_node_type_code = $9 + and edge.source_node_id = any($10::uuid[])) + or + (edge.target_node_type_code = $9 + and edge.target_node_id = any($10::uuid[])) + ) + ) + or ( + edge.edge_type_code = $11 + and ( + (edge.source_node_type_code = $9 + and edge.source_node_id = any($10::uuid[])) + or + (edge.target_node_type_code = $9 + and edge.target_node_id = any($10::uuid[])) + ) + ) + or ( + edge.edge_type_code = $12 + and ( + (edge.source_node_type_code = $4 + and edge.source_node_id = any($1::uuid[])) + or + (edge.target_node_type_code = $4 + and edge.target_node_id = any($1::uuid[])) + or + (edge.source_node_type_code = $13 + and edge.source_node_id = any($14::uuid[])) + or + (edge.target_node_type_code = $13 + and edge.target_node_id = any($14::uuid[])) + ) + ) """, visible_post_ids, person_ids, @@ -334,6 +432,13 @@ async def load_visible_subgraph( EDGE_CO_MENTION, NODE_PERSON, EDGE_AFFILIATION, + EDGE_MENTION_TEAM, + NODE_TEAM, + team_ids, + EDGE_TEAM_AFFILIATION, + EDGE_MENTION_ORGANIZATION, + NODE_CORPORATE_ENTITY, + organization_ids, ) return [edge_spec_from_row(row) for row in rows] @@ -349,6 +454,7 @@ async def hydrate_related_nodes( person_ids: list[str] = [] post_ids: list[str] = [] corp_ids: list[str] = [] + team_ids: list[str] = [] parsed: list[tuple[str, str, float]] = [] for key, score in related: node_type_code, node_id = parse_node_key(key) @@ -359,6 +465,8 @@ async def hydrate_related_nodes( post_ids.append(node_id) elif node_type_code == NODE_CORPORATE_ENTITY: corp_ids.append(node_id) + elif node_type_code == NODE_TEAM: + team_ids.append(node_id) people = { str(row["person_id"]): row @@ -381,6 +489,13 @@ async def hydrate_related_nodes( corp_ids, ) } if corp_ids else {} + teams = { + str(row["team_id"]): row + for row in await conn.fetch( + "select team_id, team_name from cataloged_team where team_id = any($1::uuid[])", + team_ids, + ) + } if team_ids else {} side_labels = await labels_for_codes( conn, [row["person_side_code"] for row in people.values()] @@ -403,6 +518,8 @@ async def hydrate_related_nodes( item["label"] = posts[node_id]["post_title"] elif node_type_code == NODE_CORPORATE_ENTITY and node_id in corps: item["label"] = corps[node_id]["entity_name"] + elif node_type_code == NODE_TEAM and node_id in teams: + item["label"] = teams[node_id]["team_name"] else: continue payload.append(item) @@ -439,3 +556,12 @@ async def related_for_entity( ) -> list[dict[str, Any]]: """Run RWR from ``entity_id`` over the account's visible subgraph.""" return await related_for_start(conn, NODE_CORPORATE_ENTITY, entity_id, visible_post_ids) + + +async def related_for_team( + conn: asyncpg.Connection, + team_id: str, + visible_post_ids: list[str], +) -> list[dict[str, Any]]: + """Run RWR from ``team_id`` over the account's visible subgraph.""" + return await related_for_start(conn, NODE_TEAM, team_id, visible_post_ids) diff --git a/backend/app/main.py b/backend/app/main.py index de06a116..adb7a20a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -116,8 +116,11 @@ persist_edges_for_post, related_for_entity, related_for_person, + related_for_team, + team_exists, visible_affiliation_post_ids, visible_mention_post_ids, + visible_team_mention_post_ids, ) from backend.app.lineage_ingestion import rebuild_lineage, visible_lineage_graph from backend.app.post_chat_ingestion import ( @@ -473,6 +476,34 @@ async def read_related_corporate_entity( } +@app.get("/api/teams/{team_id}/related") +async def read_related_team( + team_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """RWR-ranked related nodes from one cataloged team, hiding unseen posts.""" + _require_post_read(account) + async with pool.acquire() as conn: + if not await team_exists(conn, team_id): + raise HTTPException(status.HTTP_404_NOT_FOUND, "team not found") + visible_post_ids = await visible_team_mention_post_ids( + conn, team_id, lambda row: _can_see_post(account, row) + ) + if not visible_post_ids: + raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this team") + team = await conn.fetchrow( + "select team_id, team_name from cataloged_team where team_id = $1", + team_id, + ) + related = await related_for_team(conn, team_id, visible_post_ids) + return { + "team_id": str(team["team_id"]), + "team_name": team["team_name"], + "related": related, + } + + @app.get("/api/posts/{post_id}/counterparties") async def read_post_counterparties( post_id: str, diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index c79d036d..36d4fada 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -34,6 +34,7 @@ NullCorporateHierarchyInferenceClient, ) from lineageweave.fixtures import fixture_thread_cast +from lineageweave.knowledge_graph import NODE_CORPORATE_ENTITY, NODE_TEAM from lineageweave.ontology import ontology_annotations from lineageweave.post_summary import ( ACTOR_TYPE_ORGANIZATION, @@ -68,24 +69,57 @@ async def fetch_persisted_summary( post_id, ) roles = await conn.fetch( - "select actor_name, responsibility, actor_type_code, affiliated_organization_name " - "from post_summary_role where post_id = $1 order by actor_name", + """ + select role.actor_name, role.responsibility, role.actor_type_code, + role.affiliated_organization_name, + team_mention.team_id, + org_mention.corporate_entity_id + from post_summary_role role + left join cataloged_team team + on role.actor_type_code = 'prov_team' + and team.team_name = role.actor_name + and team.affiliated_organization_name + is not distinct from role.affiliated_organization_name + left join post_team_mention team_mention + on team_mention.post_id = role.post_id + and team_mention.team_id = team.team_id + left join corporate_entity org + on role.actor_type_code = 'prov_organization' + and org.entity_name = role.actor_name + left join post_organization_mention org_mention + on org_mention.post_id = role.post_id + and org_mention.corporate_entity_id = org.corporate_entity_id + where role.post_id = $1 + order by role.actor_name + """, post_id, ) - return { - "post_id": post_id, - "korean_summary": header["korean_summary"], - "key_events": [row["event_text"] for row in events], - "roles_and_responsibilities": [ + payload_roles: list[dict[str, Any]] = [] + for row in roles: + catalog_node_id = None + catalog_node_type_code = None + if row["team_id"] is not None: + catalog_node_id = str(row["team_id"]) + catalog_node_type_code = NODE_TEAM + elif row["corporate_entity_id"] is not None: + catalog_node_id = str(row["corporate_entity_id"]) + catalog_node_type_code = NODE_CORPORATE_ENTITY + payload_roles.append( { "actor_name": row["actor_name"], "responsibility": row["responsibility"], "actor_type_code": row["actor_type_code"], "affiliated_organization_name": row["affiliated_organization_name"], + "catalog_node_id": catalog_node_id, + "catalog_node_type_code": catalog_node_type_code, **ontology_annotations(row["actor_type_code"]), } - for row in roles - ], + ) + return { + "post_id": post_id, + "korean_summary": header["korean_summary"], + "key_events": [row["event_text"] for row in events], + "roles_and_responsibilities": payload_roles, } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e62ff0fa..bff7f7c6 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -22,6 +22,7 @@ import redis from lineageweave.http_client import HttpClientError, get_json, post_form +from lineageweave.knowledge_graph import knowledge_graph_edges_for_post _POSTGRES_ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave" @@ -1450,6 +1451,161 @@ def summarize(self, post_title: str, post_body: str) -> PostSummary: assert mentioning_post_count == 2, "both posts must link to the single cataloged team" assert team_mention_edge_count == 2, "each post's mention must become a real KG edge" + admin_conn = psycopg2.connect(seeded_db["dsn"]) + try: + with admin_conn.cursor() as cur: + cur.execute("select team_id from cataloged_team where team_name = '설계팀'") + team_id = str(cur.fetchone()[0]) + finally: + admin_conn.close() + + related = client.get( + f"/api/teams/{team_id}/related", + headers=headers, + ) + assert related.status_code == 200, related.text + related_ids = {node["node_id"] for node in related.json()["related"]} + assert set(post_ids) <= related_ids + summaries = [ + client.get(f"/api/posts/{post_id}/summary", headers=headers).json() + for post_id in post_ids + ] + for body in summaries: + role = body["roles_and_responsibilities"][0] + assert role["catalog_node_id"] == team_id + assert role["catalog_node_type_code"] == "node_team" + + +def test_organization_mention_only_posts_appear_in_entity_related( + client, demo_analyst_token, seeded_db +) -> None: + """An org mentioned with no affiliated person must still start a related walk.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) " + "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' " + "from source_post where post_id = %s returning post_id", + ("Org-only mention", "Test Corp was named without a person.", seeded_db["own_private_post_id"]), + ) + org_only_post_id = str(cur.fetchone()[0]) + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) values (%s, %s)", + (org_only_post_id, seeded_db["own_corp_id"]), + ) + for edge in knowledge_graph_edges_for_post( + org_only_post_id, + [], + organization_corporate_entity_ids=[seeded_db["own_corp_id"]], + ): + cur.execute( + "insert into knowledge_graph_edge (" + "source_node_type_code, source_node_id, target_node_type_code, " + "target_node_id, edge_type_code, edge_weight" + ") values (%s, %s, %s, %s, %s, %s) " + "on conflict do nothing", + ( + edge.source_node_type_code, + edge.source_node_id, + edge.target_node_type_code, + edge.target_node_id, + edge.edge_type_code, + edge.edge_weight, + ), + ) + finally: + admin_conn.close() + + response = client.get( + f"/api/corporate-entities/{seeded_db['own_corp_id']}/related", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200, response.text + related_ids = {node["node_id"] for node in response.json()["related"]} + assert org_only_post_id in related_ids + + +def test_thread_group_run_list_honors_knowledge_cutoff( + client, demo_analyst_token, seeded_db +) -> None: + """A later public post must not surface a previously hidden thread-group run.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, thread_group_key, created_at) " + "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public', %s, %s " + "from source_post where post_id = %s", + ( + "Late thread-group post", + "Written after the January cutoff.", + "late-thread-group", + "2026-01-20T12:00:00Z", + seeded_db["own_private_post_id"], + ), + ) + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'source-contract-v1', + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + ("f" * 64,), + ) + 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_lineage', %s, + (select user_account_id from user_account + where email_address = 'other.analyst@example.test'), + '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, + '2026-01-12T12:30:00Z') + returning analysis_run_id + """, + (snapshot_id, "hidden-late-thread", "b" * 64, "c" * 40), + ) + run_id = str(cur.fetchone()[0]) + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, scope_key) + values (%s, 'analysis_scope_thread_group', 'late-thread-group') + """, + (run_id,), + ) + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values (%s, 1, 'analysis_status_succeeded', '2026-01-12T12:33:00Z') + """, + (run_id,), + ) + finally: + admin_conn.close() + + listed = client.get( + "/api/analysis-runs", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert listed.status_code == 200 + ids = {run["analysis_run_id"] for run in listed.json()["analysis_runs"]} + assert run_id not in ids + assert seeded_db["visible_run_id"] in ids + def test_first_mention_of_a_new_counterparty_creates_a_real_corporate_entity( client, demo_analyst_token, seeded_db, monkeypatch diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index f9c82a86..08944337 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -49,6 +49,9 @@ run. - Hover a digest prefix to read the full code or configuration digest when you need to match the API payload. - Post-body versioning at the cutoff remains future work. +- Thread-group *run list* visibility now uses the same cutoff + (ADR 0018). A later public post cannot surface a previously hidden + thread-group run. ## References diff --git a/docs/adr/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md new file mode 100644 index 00000000..17e4236a --- /dev/null +++ b/docs/adr/0018-related-nodes-team-org-walk.md @@ -0,0 +1,67 @@ +# ADR 0018 — Related-node walks include team and organization mention edges + +**Decision status:** Accepted +**Date:** 2026-08-16 + +## Context + +ADR 0009 persists `edge_mention_team`, `edge_team_affiliation`, and +`edge_mention_organization` so a cataloged team or organization can +become a cross-post Knowledge Graph clue. The buyer-visible related-node +walk (`load_visible_subgraph` + Tong et al., 2006 random walk with +restart) still loaded only person mention, co-mention, and affiliation +edges, and returned an empty graph when a visible post had no people. +A team-only follow-up therefore never appeared as a related node, and +clicking an R&R team name had no catalog id to start a walk. + +The same temporal honesty ADR 0016 applied to run *detail* posts was +still missing from thread-group *run list* visibility: a later public +post in that thread group could surface a run the account was not +allowed to know at `knowledge_cutoff`. + +ADR 0017 already records an authorized Pending analysis-run write. +This decision is the related-node walk, not that create path. + +## Decision + +`load_visible_subgraph` loads person, team, and organization mention +channels independently. Empty person evidence is not a reason to drop +team or organization edges. `hydrate_related_nodes` labels +`cataloged_team` rows. `GET /api/teams/{team_id}/related` starts the +same RWR walk Keyman and corporate-entity related already use. +`visible_affiliation_post_ids` unions direct `post_organization_mention` +rows with person-affiliation posts so an org-only mention can start a +walk. + +The summary payload exposes `catalog_node_id` / `catalog_node_type_code` +when the R&R actor resolved to a team or organization mention on that +post. The popup turns that name into a related-node button. + +Thread-group run list visibility requires at least one ABAC-visible +`source_post` whose `created_at` is at or before `knowledge_cutoff`. + +## Consequences + +- Open a post whose R&R names 설계팀, then click the team. Sibling posts + that mention the same cataloged team appear as related nodes. +- Click a related team chip the same way you already click a person or + organization chip. +- A later public post in a thread group no longer lists a January run + that could not have known that post. + +## References + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web +Consortium. https://www.w3.org/TR/vocab-org/ + +Tong, H., Faloutsos, C., & Pan, J.-Y. (2006). Fast random walk with +restart and its applications. *Proceedings of the Sixth International +Conference on Data Mining (ICDM'06)*, 613–622. +https://doi.org/10.1109/ICDM.2006.70 + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C +Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md new file mode 100644 index 00000000..4ecb8fe8 --- /dev/null +++ b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md @@ -0,0 +1,19 @@ +# Related-node team and organization walk — doctoring + +These are the standards and papers that ground ADR 0018. Cite them in +APA 7th when you extend the walk or the catalog identity layer. + +Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web +Consortium. https://www.w3.org/TR/vocab-org/ + +Tong, H., Faloutsos, C., & Pan, J.-Y. (2006). Fast random walk with +restart and its applications. *Proceedings of the Sixth International +Conference on Data Mining (ICDM'06)*, 613–622. +https://doi.org/10.1109/ICDM.2006.70 + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C +Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/frontend/package.json b/frontend/package.json index c8f67bc8..dac24173 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.85.0", + "version": "0.86.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index dae8e167..934f150e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -649,6 +649,14 @@ describe("App, authenticated", () => { actor_type_code: "prov_organization", affiliated_organization_name: null, }, + { + actor_name: "설계팀", + responsibility: "도면 검토", + actor_type_code: "prov_team", + affiliated_organization_name: "Demo Corp", + catalog_node_id: "team-1", + catalog_node_type_code: "node_team", + }, ], }), ); @@ -750,6 +758,32 @@ describe("App, authenticated", () => { label: "Demo Corp", relevance: 0.2, }, + { + node_id: "team-1", + node_type_code: "node_team", + ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Team", + ontology_label: "Team", + label: "설계팀", + relevance: 0.15, + }, + ], + }), + ); + } + if (url.endsWith("/api/teams/team-1/related")) { + return Promise.resolve( + jsonResponse({ + team_id: "team-1", + team_name: "설계팀", + related: [ + { + node_id: "post-2", + node_type_code: "node_post", + ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + ontology_label: "Post", + label: "Linked post", + relevance: 0.6, + }, ], }), ); @@ -1201,6 +1235,30 @@ describe("App, authenticated", () => { ); }); + it("opens related nodes from an R&R team", async () => { + stubBackend(); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "R&R team: 설계팀" })); + await waitFor(() => expect(screen.getByText("Related to 설계팀")).toBeInTheDocument()); + expect(screen.getByText("Related to 설계팀").closest(".related-keymen")).toHaveTextContent( + "Linked post", + ); + }); + + it("opens related nodes from a related team chip", async () => { + stubBackend(); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" })); + await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); + await userEvent.click(screen.getByRole("button", { name: "Related nodes for 설계팀" })); + await waitFor(() => expect(screen.getByText("Related to 설계팀")).toBeInTheDocument()); + expect(screen.getByText("Related to 설계팀").closest(".related-keymen")).toHaveTextContent( + "Linked post", + ); + }); + it("opens related nodes from a related corporate entity", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3ddce1a..83fb8886 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; import { askPostChat, @@ -30,6 +30,7 @@ import { fetchPosts, fetchRelatedEntity, fetchRelatedKeymen, + fetchRelatedTeam, rebuildLineage, rebuildPeriodReports, updateTicketStatus, @@ -54,6 +55,7 @@ import { type PostLineage, type PostSummary, type RelatedNode, + type RelatedNodeType, type VocEvidence, } from "./api"; import { LineageDag } from "./LineageDag"; @@ -470,6 +472,15 @@ function VocEvidenceSection({ } const NODE_PERSON = "node_person"; +const NODE_POST = "node_post"; +const NODE_CORPORATE_ENTITY = "node_corporate_entity"; +const NODE_TEAM = "node_team"; + +const KNOWN_RELATED_NODE_TYPES = [NODE_PERSON, NODE_POST, NODE_CORPORATE_ENTITY, NODE_TEAM] as const; + +function isKnownRelatedNodeType(code: string): code is RelatedNodeType { + return (KNOWN_RELATED_NODE_TYPES as readonly string[]).includes(code); +} function relatedNodeCaption(node: RelatedNode): string { const name = node.label ?? node.node_id; @@ -482,9 +493,6 @@ function relatedNodeCaption(node: RelatedNode): string { return `${name} (${node.ontology_label ?? node.node_type_code})`; } -const NODE_POST = "node_post"; -const NODE_CORPORATE_ENTITY = "node_corporate_entity"; - const VERIFICATION_BADGE: Record = { verify_pending: "Not yet checked", verify_corroborated: "Corroborated", @@ -539,6 +547,7 @@ function KeymanPanel({ onSelectPost, focusPerson, focusEntity, + focusTeam, }: { postId: string; accessToken: string; @@ -548,6 +557,7 @@ function KeymanPanel({ onSelectPost?: (postId: string) => void; focusPerson?: { personId: string; personName: string } | null; focusEntity?: { entityId: string; entityName: string } | null; + focusTeam?: { teamId: string; teamName: string } | null; }) { const [related, setRelated] = useState(null); const [selectedName, setSelectedName] = useState(null); @@ -585,6 +595,18 @@ function KeymanPanel({ } } + async function handleSelectTeam(teamId: string, teamName: string) { + const requestId = ++relatedRequest.current; + setSelectedName(teamName); + setRelated(null); + try { + const result = await fetchRelatedTeam(accessToken, teamId); + if (requestId === relatedRequest.current) setRelated(result.related); + } catch { + if (requestId === relatedRequest.current) setRelated([]); + } + } + useEffect(() => { if (!focusPerson) return; const requestId = ++relatedRequest.current; @@ -613,6 +635,20 @@ function KeymanPanel({ }); }, [accessToken, focusEntity]); + useEffect(() => { + if (!focusTeam) return; + const requestId = ++relatedRequest.current; + setSelectedName(focusTeam.teamName); + setRelated(null); + fetchRelatedTeam(accessToken, focusTeam.teamId) + .then((result) => { + if (requestId === relatedRequest.current) setRelated(result.related); + }) + .catch(() => { + if (requestId === relatedRequest.current) setRelated([]); + }); + }, [accessToken, focusTeam]); + async function handleExtract() { setExtracting(true); setError(null); @@ -700,48 +736,67 @@ function KeymanPanel({
            {related.map((node) => { const caption = relatedNodeCaption(node); - if (node.node_type_code === NODE_POST && onSelectPost) { - return ( -
          • - -
          • - ); + const key = `${node.node_type_code}:${node.node_id}`; + if (!isKnownRelatedNodeType(node.node_type_code)) { + return
          • {caption}
          • ; } - if (node.node_type_code === NODE_PERSON) { - return ( -
          • - -
          • - ); - } - if (node.node_type_code === NODE_CORPORATE_ENTITY) { - return ( -
          • - -
          • - ); + switch (node.node_type_code) { + case NODE_POST: + if (!onSelectPost) { + return
          • {caption}
          • ; + } + return ( +
          • + +
          • + ); + case NODE_PERSON: + return ( +
          • + +
          • + ); + case NODE_CORPORATE_ENTITY: + return ( +
          • + +
          • + ); + case NODE_TEAM: + return ( +
          • + +
          • + ); + default: { + const _exhaustive: never = node.node_type_code; + return
          • {_exhaustive}
          • ; + } } - return ( -
          • {caption}
          • - ); })}
          )} @@ -1128,6 +1183,7 @@ function PostDetailPopup({ const [evaluation, setEvaluation] = useState(null); const [focusPerson, setFocusPerson] = useState<{ personId: string; personName: string } | null>(null); const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null); + const [focusTeam, setFocusTeam] = useState<{ teamId: string; teamName: string } | null>(null); function reloadKeymen() { fetchPostKeymen(accessToken, postId).then((r) => setKeymen(r.keymen)).catch(() => setKeymen([])); @@ -1156,6 +1212,7 @@ function PostDetailPopup({ setEvaluation(null); setFocusPerson(null); setFocusEntity(null); + setFocusTeam(null); fetchPost(accessToken, postId).then(setPost).catch((err) => setError(String(err))); fetchPostEvaluation(accessToken, postId) .then((r) => setEvaluation(r.responses)) @@ -1220,28 +1277,61 @@ function PostDetailPopup({ const person = isPerson ? keymen?.find((row) => row.person_name === rr.actor_name) : undefined; + const catalogId = rr.catalog_node_id; + const catalogType = rr.catalog_node_type_code; + let actorName: ReactNode = {rr.actor_name}; + if (person) { + actorName = ( + + ); + } else if (catalogType === NODE_TEAM && catalogId) { + actorName = ( + + ); + } else if (catalogType === NODE_CORPORATE_ENTITY && catalogId) { + actorName = ( + + ); + } return (
        • {actorTypeLabel} {" "} - {person ? ( - - ) : ( - {rr.actor_name} - )} + {actorName} {rr.affiliated_organization_name && ( ({rr.affiliated_organization_name}) )} @@ -1271,6 +1361,7 @@ function PostDetailPopup({ affiliateTrees={affiliateTrees} onSelectPerson={(personId, personName) => { setFocusEntity(null); + setFocusTeam(null); setFocusPerson({ personId, personName }); }} /> @@ -1299,10 +1390,12 @@ function PostDetailPopup({ node={node} onSelectPerson={(personId, personName) => { setFocusEntity(null); + setFocusTeam(null); setFocusPerson({ personId, personName }); }} onSelectEntity={(entityId, entityName) => { setFocusPerson(null); + setFocusTeam(null); setFocusEntity({ entityId, entityName }); }} /> @@ -1320,6 +1413,7 @@ function PostDetailPopup({ onSelectPost={onSelectPost} focusPerson={focusPerson} focusEntity={focusEntity} + focusTeam={focusTeam} /> {counterparties && counterparties.length > 0 && ( @@ -1331,6 +1425,7 @@ function PostDetailPopup({ onVerified={reloadCounterparties} onSelectEntity={(entityId, entityName) => { setFocusPerson(null); + setFocusTeam(null); setFocusEntity({ entityId, entityName }); }} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e35bcf3e..f9bd4068 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -73,9 +73,15 @@ export interface VocEvidence { counterparties: VocEvidenceCounterparty[]; } +export type RelatedNodeType = + | "node_person" + | "node_post" + | "node_corporate_entity" + | "node_team"; + export interface RelatedNode { node_id: string; - node_type_code: string; + node_type_code: RelatedNodeType | string; relevance: number; label?: string; person_side_code?: string; @@ -89,6 +95,8 @@ export interface PostRoleResponsibility { responsibility: string; actor_type_code: string; affiliated_organization_name: string | null; + catalog_node_id?: string | null; + catalog_node_type_code?: string | null; } export interface PostAiSummary { @@ -284,6 +292,13 @@ export function fetchRelatedEntity( return backendFetch(`/api/corporate-entities/${entityId}/related`, accessToken); } +export function fetchRelatedTeam( + accessToken: string, + teamId: string, +): Promise<{ team_id: string; team_name: string; related: RelatedNode[] }> { + return backendFetch(`/api/teams/${teamId}/related`, accessToken); +} + export function extractPostKeymen( accessToken: string, postId: string, diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5e05ef4f..5f70c606 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.85.0" +__version__ = "0.86.0" diff --git a/pyproject.toml b/pyproject.toml index 8750ae3e..0393b774 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.85.0" +version = "0.86.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py index 8aad4f6a..5353c2a9 100644 --- a/tests/test_person_mention_projection.py +++ b/tests/test_person_mention_projection.py @@ -21,13 +21,21 @@ from backend.app.keyman_ingestion import ingest_post_keymen from backend.app.knowledge_graph import ( + hydrate_related_nodes, load_visible_subgraph, persist_edges_for_post, + related_for_start, visible_mention_post_ids, ) from backend.app.post_summary_ingestion import persist_post_summary from lineageweave.keyman_extraction import OUR_SIDE, PersonMention -from lineageweave.knowledge_graph import EDGE_MENTION, NODE_PERSON, NODE_POST +from lineageweave.knowledge_graph import ( + EDGE_MENTION, + EDGE_MENTION_TEAM, + NODE_PERSON, + NODE_POST, + NODE_TEAM, +) from lineageweave.post_summary import PostSummary, RoleResponsibility _ADMIN_DSN = os.environ.get( @@ -365,3 +373,81 @@ def test_cross_post_identity_upgrade_keeps_keyman_mention_context( assert keyman_row is not None assert keyman_row[0] == "Keyman extracted this mention from the synthetic body" assert summary_count == 1 + + +async def _exercise_team_only_related_walk( + database_dsn: str, + first_post_id: str, +) -> None: + """A team mentioned on two posts must walk even when one post has no people.""" + + connection = await asyncpg.connect(database_dsn) + try: + author_id, corporate_entity_id = await connection.fetchrow( + "select author_account_id, corporate_entity_id from source_post where post_id = $1", + first_post_id, + ) + second_post_id = str( + await connection.fetchval( + """ + insert into source_post + (author_account_id, corporate_entity_id, post_title, post_body, + voc_type_code, visibility_code) + values ($1, $2, 'Team-only follow-up', '설계팀이 도면을 재검토했다.', + 'voc', 'public') + returning post_id + """, + author_id, + corporate_entity_id, + ) + ) + team_id = str( + await connection.fetchval( + """ + insert into cataloged_team (team_name, affiliated_organization_name) + values ('설계팀', 'Synthetic Corp') + returning team_id + """ + ) + ) + await connection.execute( + """ + insert into post_team_mention (post_id, team_id) + values ($1, $2), ($3, $2) + """, + first_post_id, + team_id, + second_post_id, + ) + async with connection.transaction(): + await persist_edges_for_post(connection, first_post_id) + await persist_edges_for_post(connection, second_post_id) + + team_only_edges = await load_visible_subgraph(connection, [second_post_id]) + assert any( + edge.edge_type_code == EDGE_MENTION_TEAM + and edge.source_node_id == team_id + and edge.target_node_id == second_post_id + for edge in team_only_edges + ), "a team-only post must still load its mention edge" + + related = await related_for_start( + connection, NODE_TEAM, team_id, [first_post_id, second_post_id] + ) + related_ids = {node["node_id"] for node in related} + assert first_post_id in related_ids + assert second_post_id in related_ids + hydrated = await hydrate_related_nodes( + connection, [(f"{NODE_TEAM}:{team_id}", 1.0)] + ) + assert hydrated[0]["label"] == "설계팀" + assert hydrated[0]["node_type_code"] == NODE_TEAM + finally: + await connection.close() + + +def test_team_only_posts_walk_related_nodes(projection_database: str) -> None: + """ADR 0018: team mention edges must participate in the visible RWR walk.""" + + database_dsn, post_id, _summary_person_id = projection_database.split("|") + asyncio.run(_exercise_team_only_related_walk(database_dsn, post_id)) diff --git a/uv.lock b/uv.lock index 20f9a879..b302b125 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.85.0" +version = "0.86.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From ca9bd82bfe7f7a0924e27a77a4f82ce16e3bf68d Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:08:19 +0000 Subject: [PATCH 112/161] fix: pin failed-run next actions to registered kinds Failed period-report rows now tell the operator to rebuild the report. Next-action tests pin reconstruction, measurement, and report copy to the row. A pending TEPP corpus must not claim a calibrated result. --- ARCHITECTURE.md | 4 +- CHANGELOG.d/0.84.0-tepp-analysis-run.md | 2 + .../0.86.0-related-nodes-team-org-walk.md | 2 + CHANGELOG.md | 8 +- CLAUDE.md | 3 +- docs/adr/0014-authorized-analysis-run-read.md | 5 +- frontend/src/App.test.tsx | 191 +++++++++++++++--- frontend/src/App.tsx | 66 ++++-- frontend/src/api.ts | 20 +- 9 files changed, 245 insertions(+), 56 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aa557bd7..26c817cd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -489,7 +489,9 @@ its machine `failure_code` rather than an invented caption. Failed TEPP list rows add a next-action line (open the run, then connect the measurement service) so `tepp_not_available` is not mistaken for a calibrated negative result. A failed lineage row tells the operator -to retry reconstruction, not to connect TEPP. The +to retry reconstruction, not to connect TEPP. A failed period-report +row tells the operator to rebuild the report. A pending TEPP row +does not claim a calibrated measurement. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · diff --git a/CHANGELOG.d/0.84.0-tepp-analysis-run.md b/CHANGELOG.d/0.84.0-tepp-analysis-run.md index c9653189..df127a9d 100644 --- a/CHANGELOG.d/0.84.0-tepp-analysis-run.md +++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md @@ -4,3 +4,5 @@ Seed writes `analysis_run_tepp` via `tepp_client` on the shared Demo Corp snapshot. The home list shows Failed and a kind-specific next action; detail history keeps `tepp_not_available`. Missing transport is not a fake measurement. A failed lineage row does not mention TEPP. +A failed period-report row rebuilds the report. A pending TEPP row +does not claim a calibrated measurement. diff --git a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md index 4efa8100..4989a054 100644 --- a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md +++ b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md @@ -1,2 +1,4 @@ Related-node walks include team and organization mention edges. Click an R&R team to open sibling posts. Thread-group run lists honor knowledge_cutoff. +Failed period-report rows rebuild the report; a pending TEPP corpus +does not claim a calibrated measurement. diff --git a/CHANGELOG.md b/CHANGELOG.md index 434c4d63..6bf4dc62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ All notable changes to this project are documented here. Format follows - Thread-group analysis-run *lists* now require an in-cutoff visible post. A later public post in that thread group no longer surfaces a January run the account was not allowed to know. +- Failed period-report rows tell the operator to rebuild the report. + Next-action copy is pinned to the registered run kinds. A pending + TEPP corpus does not claim a calibrated measurement. ## [0.85.0] - 2026-08-16 @@ -62,7 +65,10 @@ All notable changes to this project are documented here. Format follows snapshot-count inserts once counts exist so a re-run does not hit the freeze trigger. A failed lineage row tells the operator to retry reconstruction; only a failed TEPP row mentions the measurement - service. Stacked PRs now run the same GitHub Checks as PRs to main. + service. A failed period-report row tells the operator to rebuild + the report from a current snapshot. A pending TEPP row does not + claim a calibrated measurement. Stacked PRs now run the same + GitHub Checks as PRs to main. ## [0.83.0] - 2026-08-16 diff --git a/CLAUDE.md b/CLAUDE.md index 2f89bada..a1112758 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,8 @@ theta or a local psychometric substitute. The home list caption stays `kind · status · entity`; the machine failure code is detail-only (ADR 0014). Open a Failed TEPP row, then connect a live TEPP transport. A failed lineage row retries reconstruction -- it does not -mention TEPP. +mention TEPP. A failed period-report row rebuilds the report. A +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). diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 9188187f..29c074c8 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -45,7 +45,10 @@ kinds without a second application. The TEPP run is Failed / keeps that machine code off the caption (this decision) and instead tells the operator to open the TEPP run, then connect the measurement service. A failed lineage row tells the operator to retry -reconstruction, not to connect TEPP. The detail now shows the legal +reconstruction, not to connect TEPP. A failed period-report row +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 diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 934f150e..8618fcfb 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -60,7 +60,9 @@ describe("App, authenticated", () => { searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; failedLineageRun?: boolean; + failedReportRun?: boolean; succeededTeppRun?: boolean; + pendingTeppRun?: boolean; }) { const statusLabel: Record = { open: "Open", @@ -171,21 +173,19 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", has_commitment: true, ticket }), ); } - if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { + if (url.endsWith("/api/analysis-runs/run-demo-report")) { return Promise.resolve( jsonResponse({ - analysis_run_id: "run-demo-tepp", - run_kind_code: "analysis_run_tepp", - run_kind_label: "TEPP measurement", + analysis_run_id: "run-demo-report", + run_kind_code: "analysis_run_report", + run_kind_label: "Period report", scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: options?.succeededTeppRun - ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", + status_code: "analysis_status_failed", + status_label: "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", - requested_at: "2026-01-12T12:34:00Z", + requested_at: "2026-01-12T12:38:00Z", source_counts: [ { count_type_code: "analysis_count_document", @@ -193,32 +193,90 @@ describe("App, authenticated", () => { count_value: 3, }, ], - visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + visible_posts: [], status_history: [ { status_ordinal: 1, status_code: "analysis_status_pending", status_label: "Pending", - occurred_at: "2026-01-12T12:35:00Z", + occurred_at: "2026-01-12T12:39:00Z", }, { status_ordinal: 2, - status_code: "analysis_status_running", - status_label: "Running", - occurred_at: "2026-01-12T12:36:00Z", + status_code: "analysis_status_failed", + status_label: "Failed", + occurred_at: "2026-01-12T12:40:00Z", + failure_code: "period_report_rebuild_failed", }, + ], + }), + ); + } + if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { + const teppStatus = options?.succeededTeppRun + ? "analysis_status_succeeded" + : options?.pendingTeppRun + ? "analysis_status_pending" + : "analysis_status_failed"; + const teppLabel = options?.succeededTeppRun + ? "Succeeded" + : options?.pendingTeppRun + ? "Pending" + : "Failed"; + return Promise.resolve( + jsonResponse({ + analysis_run_id: "run-demo-tepp", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: teppStatus, + status_label: teppLabel, + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:34:00Z", + source_counts: [ { - status_ordinal: 3, - status_code: options?.succeededTeppRun - ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", - occurred_at: "2026-01-12T12:37:00Z", - ...(options?.succeededTeppRun - ? {} - : { failure_code: "tepp_not_available" }), + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, }, ], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + status_history: options?.pendingTeppRun + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + ] + : [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + { + status_ordinal: 3, + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", + occurred_at: "2026-01-12T12:37:00Z", + ...(options?.succeededTeppRun + ? {} + : { failure_code: "tepp_not_available" }), + }, + ], }), ); } @@ -331,8 +389,14 @@ describe("App, authenticated", () => { scope_entity_name: "Demo Corp", status_code: options?.succeededTeppRun ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", + : options?.pendingTeppRun + ? "analysis_status_pending" + : "analysis_status_failed", + status_label: options?.succeededTeppRun + ? "Succeeded" + : options?.pendingTeppRun + ? "Pending" + : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -343,6 +407,29 @@ describe("App, authenticated", () => { }, ], }, + ...(options?.failedReportRun + ? [ + { + analysis_run_id: "run-demo-report", + run_kind_code: "analysis_run_report" as const, + run_kind_label: "Period report", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_failed" as const, + status_label: "Failed", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:38:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + }, + ] + : []), ], }), ); @@ -1615,18 +1702,58 @@ describe("App, authenticated", () => { stubBackend({ failedLineageRun: true }); render(); - const list = await screen.findByRole("list", { name: "Analysis runs" }); - expect(list).toHaveTextContent("Lineage reconstruction · Failed · Demo Corp"); - expect(list).toHaveTextContent( + await screen.findByRole("list", { name: "Analysis runs" }); + const lineageButton = screen.getByRole("button", { + name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", + }); + const teppButton = screen.getByRole("button", { + 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.", ); - expect(list).toHaveTextContent( + expect(lineageButton).not.toHaveTextContent("measurement service"); + expect(teppButton).toHaveTextContent( "Open this run to see why it failed, then connect the measurement service and re-run.", ); - const lineageButton = screen.getByRole("button", { - name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", + expect(teppButton).not.toHaveTextContent("reconstruction"); + }); + + it("does not tell a failed period report to connect the measurement service", async () => { + stubBackend({ failedReportRun: true }); + render(); + + const reportButton = await screen.findByRole("button", { + name: "Open analysis run: Period report · Failed · Demo Corp", }); - expect(lineageButton).not.toHaveTextContent("measurement service"); + expect(reportButton).toHaveTextContent( + "Open this run to see why it failed, then rebuild the period report from a current snapshot.", + ); + expect(reportButton).not.toHaveTextContent("measurement service"); + expect(reportButton).not.toHaveTextContent("reconstruction"); + + await userEvent.click(reportButton); + expect( + await screen.findByText( + "No posts were available at this cutoff for the period report. Open a later run, or ask an administrator to capture a newer snapshot.", + ), + ).toBeInTheDocument(); + }); + + it("does not tell a pending TEPP run that it already measured", async () => { + stubBackend({ pendingTeppRun: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Pending · Demo Corp", + }), + ); + expect( + await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."), + ).toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); }); it("does not tell a succeeded TEPP run to replace Failed", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 83fb8886..35a080aa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1468,8 +1468,12 @@ function analysisRunNextAction(run: AnalysisRun): string | null { 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."; - default: - return "Open this run to see why it failed, then retry after the blocking service is connected."; + case "analysis_run_report": + return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; + default: { + const unexpected: never = run.run_kind_code; + return unexpected; + } } } @@ -1477,32 +1481,60 @@ function analysisRunNextAction(run: AnalysisRun): string | null { * Empty-corpus copy that tells the operator what to do next. */ function analysisRunEmptyPostsHint(run: AnalysisRun): string { - if (run.run_kind_code === "analysis_run_tepp") { - return ( - "No posts were available at this cutoff for TEPP to measure. " + - "Open a later run, or ask an administrator to capture a newer snapshot." - ); + switch (run.run_kind_code) { + case "analysis_run_tepp": + return ( + "No posts were available at this cutoff for TEPP to measure. " + + "Open a later run, or ask an administrator to capture a newer snapshot." + ); + case "analysis_run_lineage": + return ( + "No posts were available at this cutoff for reconstruction. " + + "Open a later run, or ask an administrator to capture a newer snapshot." + ); + case "analysis_run_report": + return ( + "No posts were available at this cutoff for the period report. " + + "Open a later run, or ask an administrator to capture a newer snapshot." + ); + default: { + const unexpected: never = run.run_kind_code; + return unexpected; + } } - return ( - "No posts were available at this cutoff. Open a later run, or ask an " + - "administrator to capture a newer snapshot." - ); } /** * Corpus copy for a TEPP run that already has cutoff posts. * * Those titles are the measurement bag, not a reconstruction result. + * Pending or running must not claim a calibrated measurement. */ function analysisRunCorpusHint(run: AnalysisRun): string | null { if (run.run_kind_code !== "analysis_run_tepp") return null; - if (run.status_code === "analysis_status_failed") { - return ( - "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + - "transport, then re-run, to replace Failed with a calibrated result." - ); + switch (run.status_code) { + case "analysis_status_failed": + return ( + "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + + "transport, then re-run, to replace Failed with a calibrated result." + ); + case "analysis_status_succeeded": + return "These posts are the cutoff corpus this TEPP run measured."; + case "analysis_status_pending": + case "analysis_status_running": + return "These posts are the cutoff corpus TEPP will measure once this run finishes."; + case "analysis_status_cancelled": + return ( + "These posts are the cutoff corpus this TEPP run would have measured. " + + "The run was cancelled before a calibrated result." + ); + case null: + return "These posts are the cutoff corpus attached to this TEPP run."; + default: { + const unexpected: never = run.status_code; + return unexpected; + } } - return "These posts are the cutoff corpus this TEPP run measured."; } /** Git-style prefix. The full digest stays on `title` for verification. */ diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f9bd4068..3385d517 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -514,9 +514,23 @@ export interface AnalysisRunCount { count_value: number; } +/** Registry kinds from `analysis_run.run_kind_code` (migration 0018). */ +export type AnalysisRunKindCode = + | "analysis_run_lineage" + | "analysis_run_report" + | "analysis_run_tepp"; + +/** Registry statuses from `analysis_run_status_event.status_code`. */ +export type AnalysisRunStatusCode = + | "analysis_status_pending" + | "analysis_status_running" + | "analysis_status_succeeded" + | "analysis_status_failed" + | "analysis_status_cancelled"; + export interface AnalysisRunStatusEvent { status_ordinal: number; - status_code: string; + status_code: AnalysisRunStatusCode; status_label: string; occurred_at: string; failure_code?: string; @@ -524,12 +538,12 @@ export interface AnalysisRunStatusEvent { export interface AnalysisRun { analysis_run_id: string; - run_kind_code: string; + run_kind_code: AnalysisRunKindCode; run_kind_label: string; scope_kind_code: string; scope_kind_label: string; scope_entity_name?: string; - status_code: string | null; + status_code: AnalysisRunStatusCode | null; status_label: string | null; knowledge_cutoff: string; requested_at: string; From 7f2d4bc011202ec8f2433c18ae10b1ae62aff2f6 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:13:33 +0900 Subject: [PATCH 113/161] feat(ui): show embedded post images instead of raw base64 (v0.86.1) Open a post or evidence panel and see each data-URI picture in document order. The popup no longer dumps the base64 wall. Remote http(s) image URLs stay unloaded. Extract Keyman or Ask still runs OCR on those images. Rebased onto live #74 head ca9bd82 after #128 squash-merged. --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 11 +++++ docs/image-content-schema.md | 26 ++++++++++ frontend/package.json | 2 +- frontend/src/App.css | 27 ++++++++++ frontend/src/App.test.tsx | 20 +++++++- frontend/src/App.tsx | 5 +- frontend/src/PostBody.tsx | 33 +++++++++++++ frontend/src/index.css | 5 ++ frontend/src/postBodyDisplay.test.ts | 74 ++++++++++++++++++++++++++++ frontend/src/postBodyDisplay.ts | 72 +++++++++++++++++++++++++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 14 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 frontend/src/PostBody.tsx create mode 100644 frontend/src/postBodyDisplay.test.ts create mode 100644 frontend/src/postBodyDisplay.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 26c817cd..a8d082f0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,7 @@ flowchart LR | `chunking.py` | Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order | | `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` | | `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) | -| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl) | +| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the buyer sees the picture, not the base64 string; GET does not call the vision client. | | `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport | | `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread | | `lineage_persistence.py` | Flattens reconstruct trees into `post_lineage_edge` row specs (parent, child, fused_score) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf4dc62..fd871751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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.1] - 2026-08-16 + +### Changed + +- Opening a post or its evidence panel now shows each embedded + `data:image` picture in document order, with the surrounding sentences + as text. The raw base64 string is no longer dumped into the popup. + Remote `http(s)` image URLs stay unloaded. After `make seed`, a post + whose body includes a data-URI image shows the picture; Extract Keyman + or Ask still runs OCR on that image for search. + ## [0.86.0] - 2026-08-16 ### Added diff --git a/docs/image-content-schema.md b/docs/image-content-schema.md index f023d56a..4dae03dc 100644 --- a/docs/image-content-schema.md +++ b/docs/image-content-schema.md @@ -82,6 +82,18 @@ picture sat relative to the surrounding paragraphs." | `chunk_position` | `integer not null` | 0-based index among ALL of this document's chunks (text and image together) -- matches `Chunk.index` from `chunk_by_dom` | | primary key | `(source_document_id, chunk_position)` | one image slot per position per document | +## Viewer contract (before persistence exists) + +The demo popup does not yet read these tables. It splits the live +`post_body` the same way `extract_base64_images` does: each +`data:image/...;base64,...` payload becomes an `` at its original +character offset, and the surrounding HTML is shown as text. A buyer who +opens the post sees the picture that sat between the paragraphs, not the +base64 wall. Remote `src="https://..."` tags are stripped, never fetched. +OCR, caption, and tag search still require the vision client on extract / +Ask (Li et al., 2023; Radford et al., 2021) and, in a real deployment, +the tables below. + ## Query shapes this supports - **"Find images whose extracted text or tags match a search query, then @@ -105,3 +117,17 @@ picture sat relative to the surrounding paragraphs." ON CONFLICT DO NOTHING` before the provider call, or a short-lived lease row) to close that race; this schema documents the storage guarantee, not that concurrency control. + +## References + +Li, M., Lv, T., Chen, J., Cui, L., Lu, Y., Florencio, D., Zhang, C., Li, Z., +& Wei, F. (2023). TrOCR: Transformer-based optical character recognition +with pre-trained models. *Proceedings of the AAAI Conference on Artificial +Intelligence, 37*(11), 13094–13102. https://doi.org/10.1609/aaai.v37i11.26538 + +Radford, A., Kim, J. W., Hallacy, C., Ramesh, A., Goh, G., Agarwal, S., +Sastry, G., Askell, A., Mishkin, P., Clark, J., Krueger, G., & Sutskever, I. +(2021). Learning transferable visual models from natural language +supervision. In M. Meila & T. Zhang (Eds.), *Proceedings of the 38th +International Conference on Machine Learning* (pp. 8748–8763). PMLR. +https://proceedings.mlr.press/v139/radford21a.html diff --git a/frontend/package.json b/frontend/package.json index dac24173..803acd91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.0", + "version": "0.86.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 8f38b4dd..b3fab25d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -108,9 +108,36 @@ } .post-body { + display: flex; + flex-direction: column; + gap: var(--post-body-gap); +} + +.post-body-text { + margin: 0; white-space: pre-wrap; } +.post-embedded-image { + margin: 0; + padding: var(--post-image-padding); + border: 1px solid var(--post-image-border); + border-radius: var(--post-image-radius); + background: var(--post-image-bg); +} + +.post-embedded-image img { + display: block; + max-width: 100%; + height: auto; +} + +.post-embedded-image figcaption { + margin-top: 0.4rem; + font-size: 0.85rem; + color: var(--text); +} + .popup-placeholder { margin-top: 1.5rem; padding: 1rem; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 8618fcfb..65d25259 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; + postBody?: string; }) { const statusLabel: Record = { open: "Open", @@ -668,7 +669,7 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", post_title: "Public post", - post_body: "The full body text.", + post_body: options?.postBody ?? "The full body text.", voc_type_code: "voc", voc_type_label: "Voice of Customer", visibility_code: "public", @@ -1088,6 +1089,23 @@ describe("App, authenticated", () => { await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); }); + it("shows an embedded invoice image instead of the raw base64 string", async () => { + const tinyPng = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + stubBackend({ + postBody: `

          Quote attached.

          Please confirm.

          `, + }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + const image = await screen.findByRole("img", { name: /embedded image at character offset/i }); + expect(image).toHaveAttribute("src", `data:image/png;base64,${tinyPng}`); + expect(screen.getByText("Quote attached.")).toBeInTheDocument(); + expect(screen.getByText("Please confirm.")).toBeInTheDocument(); + expect(screen.getByText(/Extract Keyman or ask a question/)).toBeInTheDocument(); + expect(screen.queryByText(new RegExp(tinyPng))).not.toBeInTheDocument(); + }); + it("fetches and renders the post list, then opens a detail popup on click", async () => { const fetchMock = stubBackend(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 35a080aa..a511f713 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,6 +59,7 @@ import { type VocEvidence, } from "./api"; import { LineageDag } from "./LineageDag"; +import { PostBody } from "./PostBody"; import { subgraphForPost } from "./lineageLayout"; import "./App.css"; @@ -119,7 +120,7 @@ function EvidencePanel({ {post && ( <>

          {post.post_title}

          -

          {post.post_body}

          + )} @@ -1245,7 +1246,7 @@ function PostDetailPopup({ {post.visibility_label ?? post.visibility_code} ·{" "} {new Date(post.created_at).toLocaleString()}

          -

          {post.post_body}

          +

          요약 (Summary)

          diff --git a/frontend/src/PostBody.tsx b/frontend/src/PostBody.tsx new file mode 100644 index 00000000..3ff77b53 --- /dev/null +++ b/frontend/src/PostBody.tsx @@ -0,0 +1,33 @@ +import { splitPostBody, type PostBodySegment } from "./postBodyDisplay"; + +function renderSegment(segment: PostBodySegment, index: number) { + switch (segment.kind) { + case "text": + return ( +

          + {segment.text} +

          + ); + case "image": + return ( +
          + {`Embedded +
          + Image from this post. Extract Keyman or ask a question to read text + inside it. +
          +
          + ); + default: { + const _exhaustive: never = segment; + throw new Error(`unexpected post body segment: ${JSON.stringify(_exhaustive)}`); + } + } +} + +export function PostBody({ body }: { body: string }) { + return
          {splitPostBody(body).map(renderSegment)}
          ; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5fb33130..53f4db2a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -8,6 +8,11 @@ --accent-bg: rgba(170, 59, 255, 0.1); --accent-border: rgba(170, 59, 255, 0.5); --social-bg: rgba(244, 243, 236, 0.5); + --post-body-gap: 0.75rem; + --post-image-padding: 0.75rem; + --post-image-radius: 8px; + --post-image-border: var(--border); + --post-image-bg: var(--code-bg); --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts new file mode 100644 index 00000000..f3092cea --- /dev/null +++ b/frontend/src/postBodyDisplay.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { splitPostBody } from "./postBodyDisplay"; + +/** 1x1 transparent PNG — the same synthetic fixture the Python vision tests use. */ +const TINY_PNG_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +describe("splitPostBody", () => { + it("leaves a plain-text post unchanged so existing popups keep their wording", () => { + expect(splitPostBody("The full body text.")).toEqual([ + { kind: "text", text: "The full body text." }, + ]); + }); + + it("keeps comparison operators that look like broken HTML", () => { + expect(splitPostBody("qty < 50 and price > 10")).toEqual([ + { kind: "text", text: "qty < 50 and price > 10" }, + ]); + }); + + it("renders a data-URI image as its own segment and never leaks the raw base64 into text", () => { + const html = + `

          Quote attached.

          Please confirm.

          `; + const segments = splitPostBody(html); + + expect(segments).toEqual([ + { kind: "text", text: "Quote attached." }, + { + kind: "image", + src: `data:image/png;base64,${TINY_PNG_B64}`, + mimeType: "image/png", + position: html.indexOf(" { + const html = + `

          between

          ` + + ``; + const segments = splitPostBody(html); + expect(segments.map((segment) => segment.kind)).toEqual(["image", "text", "image"]); + expect(segments[1]).toEqual({ kind: "text", text: "between" }); + expect(segments[0]?.kind === "image" && segments[0].position).toBe(0); + expect(segments[2]?.kind === "image" && segments[2].position).toBeGreaterThan(0); + }); + + it("tells the operator to re-export when the base64 payload is not decodable", () => { + const html = ''; + expect(splitPostBody(html)).toEqual([ + { + kind: "text", + text: "Embedded image could not be decoded. Re-export the source post and open it again.", + }, + ]); + }); + + it("does not turn a remote http img into a loaded image", () => { + const html = '

          See

          end

          '; + const segments = splitPostBody(html); + expect(segments.every((segment) => segment.kind === "text")).toBe(true); + expect(segments.map((segment) => (segment.kind === "text" ? segment.text : "")).join(" ")).toContain( + "See", + ); + expect(JSON.stringify(segments)).not.toContain("https://example.test"); + }); +}); diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts new file mode 100644 index 00000000..c6ea29fd --- /dev/null +++ b/frontend/src/postBodyDisplay.ts @@ -0,0 +1,72 @@ +/** + * Split a raw `post_body` into text and in-place data-URI images. + * + * The popup used to dump the source string, so a buyer who opened a post + * with an embedded invoice saw a base64 wall instead of the picture. + * Only `data:image/...;base64,...` payloads are turned into images — + * remote `http(s)` img tags are stripped, never fetched. + */ + +export type PostBodySegment = + | { kind: "text"; text: string } + | { kind: "image"; src: string; mimeType: string; position: number }; + +const DATA_URI_IMG = + /]*\bsrc\s*=\s*["']data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)["'][^>]*>/gi; + +const HTML_TAG = /<\/?[a-zA-Z][^>]*>/g; + +const UNDECODEABLE_IMAGE = + "Embedded image could not be decoded. Re-export the source post and open it again."; + +function stripHtmlTags(text: string): string { + return text.replace(HTML_TAG, " ").replace(/\s+/g, " ").trim(); +} + +function isDecodableBase64(raw: string): boolean { + if (raw.length === 0) { + return false; + } + try { + atob(raw); + return true; + } catch { + return false; + } +} + +function pushText(segments: PostBodySegment[], raw: string): void { + const text = stripHtmlTags(raw); + if (text) { + segments.push({ kind: "text", text }); + } +} + +export function splitPostBody(body: string): PostBodySegment[] { + const segments: PostBodySegment[] = []; + const pattern = new RegExp(DATA_URI_IMG.source, "gi"); + let lastIndex = 0; + let match = pattern.exec(body); + while (match !== null) { + pushText(segments, body.slice(lastIndex, match.index)); + const mimeType = match[1]; + const rawB64 = match[2].replace(/\s+/g, ""); + if (isDecodableBase64(rawB64)) { + segments.push({ + kind: "image", + src: `data:${mimeType};base64,${rawB64}`, + mimeType, + position: match.index, + }); + } else { + segments.push({ kind: "text", text: UNDECODEABLE_IMAGE }); + } + lastIndex = match.index + match[0].length; + match = pattern.exec(body); + } + pushText(segments, body.slice(lastIndex)); + if (segments.length === 0) { + return [{ kind: "text", text: body }]; + } + return segments; +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5f70c606..48bf6e48 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.0" +__version__ = "0.86.1" diff --git a/pyproject.toml b/pyproject.toml index 0393b774..eb2e2631 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.0" +version = "0.86.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/uv.lock b/uv.lock index b302b125..c759df26 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.0" +version = "0.86.1" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 6f79ecb6090ef9b73d64b1ea63c5207d8bfa07ed Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:18:46 +0900 Subject: [PATCH 114/161] fix: bind R&R catalog ids without homonym joins (v0.86.2) (#141) Persist cataloged_team_id and cataloged_corporate_entity_id on post_summary_role (ADR 0019). fetch_persisted_summary reads those columns and does not join corporate_entity by entity_name. Team related matches person/entity 403/404. Rebased onto live #74 head 7f2d4bc after #140 took v0.86.1. --- ARCHITECTURE.md | 5 +- CHANGELOG.d/0.86.2-role-catalog-identity.md | 3 + CHANGELOG.md | 10 ++ backend/app/post_summary_ingestion.py | 99 +++++++++---------- backend/tests/test_api.py | 97 ++++++++++++++++++ docker/postgres-init/Dockerfile | 1 + docs/adr/0018-related-nodes-team-org-walk.md | 5 +- docs/adr/0019-role-catalog-identity.md | 65 ++++++++++++ .../RELATED_NODE_TEAM_ORG_REFERENCES.md | 12 +++ frontend/package.json | 2 +- lineageweave/__init__.py | 2 +- migrations/0001_initial_schema.sql | 9 ++ migrations/0019_role_catalog_identity.sql | 46 +++++++++ .../rollback/0019_role_catalog_identity.sql | 8 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 1 + tests/test_ingestion_transaction_contracts.py | 38 +++++++ tests/test_person_mention_projection.py | 95 +++++++++++++++++- uv.lock | 2 +- 19 files changed, 442 insertions(+), 60 deletions(-) create mode 100644 CHANGELOG.d/0.86.2-role-catalog-identity.md create mode 100644 docs/adr/0019-role-catalog-identity.md create mode 100644 migrations/0019_role_catalog_identity.sql create mode 100644 migrations/rollback/0019_role_catalog_identity.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a8d082f0..b66c7cde 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -849,8 +849,9 @@ new table needed. `lineageweave/knowledge_graph.py`'s `knowledge_graph_edges_for_post` extended with three new edge kinds (`edge_mention_team`, `edge_team_affiliation`, `edge_mention_organization`); `backend/app/post_summary_ingestion.py`'s `persist_post_summary` now -resolves each R&R actor's identity and calls the same -`persist_edges_for_post` Keyman ingestion already uses. A person R&R +resolves each R&R actor's identity, stores that id on +`post_summary_role` (ADR 0019 — `entity_name` is not unique), and calls +the same `persist_edges_for_post` Keyman ingestion already uses. A person R&R actor is opportunistically joined to an existing `cataloged_person` row by name (never originated by R&R itself -- documented gap in the ADR: `cataloged_person` needs `person_side_code`, which R&R's prompt does diff --git a/CHANGELOG.d/0.86.2-role-catalog-identity.md b/CHANGELOG.d/0.86.2-role-catalog-identity.md new file mode 100644 index 00000000..a56b3d47 --- /dev/null +++ b/CHANGELOG.d/0.86.2-role-catalog-identity.md @@ -0,0 +1,3 @@ +R&R organization buttons walk the catalog id stored on the role row. A +shared display name no longer attaches a homonym. Team related matches +person/entity 403/404. diff --git a/CHANGELOG.md b/CHANGELOG.md index fd871751..13aeb02f 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.2] - 2026-08-16 + +### Fixed + +- An R&R organization button now walks the catalog id stored on that + role row (ADR 0019). Two catalog orgs can share a display name; open + the post, click the name, and you stay on the resolved org — not a + homonym. `GET /api/teams/{id}/related` matches person/entity authz: + another corp's private-only team is 403; an unknown UUID is 404. + ## [0.86.1] - 2026-08-16 ### Changed diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 36d4fada..3febf9b2 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -1,10 +1,11 @@ """Persist and load the popup's Korean summary / key events / R&R. -ADR 0009: an R&R actor is not just per-post free text -- when it is a -team or organization, it is resolved to a shared catalog identity -(``cataloged_team`` / ``corporate_entity``) and a Knowledge Graph -mention edge is written, so the same "설계팀" or organization named -across two posts becomes one linkable node, not two unrelated strings. +ADR 0009 / 0019: an R&R actor is not just per-post free text -- when it +is a team or organization, it is resolved to a shared catalog identity +(``cataloged_team`` / ``corporate_entity``) stored on the role row and +a Knowledge Graph mention edge is written, so the same "설계팀" or +organization named across two posts becomes one linkable node. Fetch +never reconstructs that id by ``entity_name``; that column is not unique. A person actor is opportunistically joined to an *existing* ``cataloged_person`` row by name when Keyman extraction has already cataloged that name. The R&R evidence is written to @@ -57,7 +58,12 @@ async def fetch_persisted_summary( conn: asyncpg.Connection, post_id: str ) -> dict[str, Any] | None: - """Return the stored summary payload, or None when none has been written.""" + """Return the stored summary payload, or None when none has been written. + + ``catalog_node_id`` comes from the role row's catalog foreign keys + (ADR 0019). This function does not join ``corporate_entity`` by + ``entity_name``. + """ header = await conn.fetchrow( "select korean_summary from post_summary_result where post_id = $1", post_id, @@ -72,23 +78,9 @@ async def fetch_persisted_summary( """ select role.actor_name, role.responsibility, role.actor_type_code, role.affiliated_organization_name, - team_mention.team_id, - org_mention.corporate_entity_id + role.cataloged_team_id, + role.cataloged_corporate_entity_id from post_summary_role role - left join cataloged_team team - on role.actor_type_code = 'prov_team' - and team.team_name = role.actor_name - and team.affiliated_organization_name - is not distinct from role.affiliated_organization_name - left join post_team_mention team_mention - on team_mention.post_id = role.post_id - and team_mention.team_id = team.team_id - left join corporate_entity org - on role.actor_type_code = 'prov_organization' - and org.entity_name = role.actor_name - left join post_organization_mention org_mention - on org_mention.post_id = role.post_id - and org_mention.corporate_entity_id = org.corporate_entity_id where role.post_id = $1 order by role.actor_name """, @@ -98,11 +90,11 @@ async def fetch_persisted_summary( for row in roles: catalog_node_id = None catalog_node_type_code = None - if row["team_id"] is not None: - catalog_node_id = str(row["team_id"]) + if row["cataloged_team_id"] is not None: + catalog_node_id = str(row["cataloged_team_id"]) catalog_node_type_code = NODE_TEAM - elif row["corporate_entity_id"] is not None: - catalog_node_id = str(row["corporate_entity_id"]) + elif row["cataloged_corporate_entity_id"] is not None: + catalog_node_id = str(row["cataloged_corporate_entity_id"]) catalog_node_type_code = NODE_CORPORATE_ENTITY payload_roles.append( { @@ -218,44 +210,51 @@ async def _replace_summary_projection( ordinal, event_text, ) - for role in summary.roles_and_responsibilities: + # ADR 0009 / 0019: resolve catalog identity before writing the role + # row so fetch never reconstructs it by a non-unique name. + for role_index, role in enumerate(summary.roles_and_responsibilities): + cataloged_team_id = None + cataloged_corporate_entity_id = None + if role.actor_type_code == ACTOR_TYPE_TEAM: + cataloged_team_id = await upsert_team( + conn, + role.actor_name, + role.affiliated_organization_name, + candidates, + ) + elif role.actor_type_code == ACTOR_TYPE_ORGANIZATION: + cataloged_corporate_entity_id = resolved_organization_ids.get( + role_index + ) await conn.execute( "insert into post_summary_role " "(post_id, actor_name, responsibility, actor_type_code, " - "affiliated_organization_name) values ($1, $2, $3, $4, $5)", + "affiliated_organization_name, cataloged_team_id, " + "cataloged_corporate_entity_id) values " + "($1, $2, $3, $4, $5, $6, $7)", post_id, role.actor_name, role.responsibility, role.actor_type_code, role.affiliated_organization_name, + cataloged_team_id, + cataloged_corporate_entity_id, ) - - # ADR 0009: cross-post identity resolution for team/organization/person - # actors -- see module docstring. - for role_index, role in enumerate(summary.roles_and_responsibilities): - if role.actor_type_code == ACTOR_TYPE_TEAM: - team_id = await upsert_team( - conn, - role.actor_name, - role.affiliated_organization_name, - candidates, - ) + if cataloged_team_id is not None: await conn.execute( "insert into post_team_mention (post_id, team_id) values ($1, $2) " "on conflict do nothing", post_id, - team_id, + cataloged_team_id, + ) + elif cataloged_corporate_entity_id is not None: + await conn.execute( + "insert into post_organization_mention " + "(post_id, corporate_entity_id) values ($1, $2) " + "on conflict do nothing", + post_id, + cataloged_corporate_entity_id, ) - elif role.actor_type_code == ACTOR_TYPE_ORGANIZATION: - corporate_entity_id = resolved_organization_ids.get(role_index) - if corporate_entity_id is not None: - await conn.execute( - "insert into post_organization_mention " - "(post_id, corporate_entity_id) values ($1, $2) " - "on conflict do nothing", - post_id, - corporate_entity_id, - ) elif role.actor_type_code == ACTOR_TYPE_PERSON: person_row = await conn.fetchrow( "select person_id from cataloged_person where person_name = $1 limit 1", diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index bff7f7c6..21c71bc9 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1201,6 +1201,44 @@ def test_unknown_corporate_entity_related_is_not_found( assert response.status_code == 404 +def test_team_only_on_other_corp_private_post_is_forbidden( + client, demo_analyst_token, seeded_db +) -> None: + """A team mentioned only on another corp's private post must 403.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into cataloged_team (team_name, affiliated_organization_name) " + "values ('비공개 설계팀', 'Other Corp') returning team_id" + ) + team_id = str(cur.fetchone()[0]) + cur.execute( + "insert into post_team_mention (post_id, team_id) values (%s, %s)", + (seeded_db["other_private_post_id"], team_id), + ) + finally: + admin_conn.close() + + response = client.get( + f"/api/teams/{team_id}/related", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + +def test_unknown_team_related_is_not_found(client, demo_analyst_token) -> None: + """An unknown team UUID must 404, matching person and entity related.""" + + response = client.get( + f"/api/teams/{uuid.uuid4()}/related", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 404 + + def test_keyman_only_on_other_corp_private_post_is_forbidden(client, demo_analyst_token, seeded_db) -> None: response = client.get( f"/api/keymen/{seeded_db['hidden_person_id']}/related", @@ -1528,6 +1566,65 @@ def test_organization_mention_only_posts_appear_in_entity_related( assert org_only_post_id in related_ids +def test_private_other_corp_organization_mention_does_not_leak( + client, demo_analyst_token, seeded_db +) -> None: + """The org-mention UNION must still apply ABAC per post.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) " + "values (%s, %s) on conflict do nothing", + (seeded_db["other_private_post_id"], seeded_db["own_corp_id"]), + ) + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) " + "values (%s, %s) on conflict do nothing", + (seeded_db["other_private_post_id"], seeded_db["other_corp_id"]), + ) + for entity_id in (seeded_db["own_corp_id"], seeded_db["other_corp_id"]): + for edge in knowledge_graph_edges_for_post( + seeded_db["other_private_post_id"], + [], + organization_corporate_entity_ids=[entity_id], + ): + cur.execute( + "insert into knowledge_graph_edge (" + "source_node_type_code, source_node_id, target_node_type_code, " + "target_node_id, edge_type_code, edge_weight" + ") values (%s, %s, %s, %s, %s, %s) " + "on conflict do nothing", + ( + edge.source_node_type_code, + edge.source_node_id, + edge.target_node_type_code, + edge.target_node_id, + edge.edge_type_code, + edge.edge_weight, + ), + ) + finally: + admin_conn.close() + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + own = client.get( + f"/api/corporate-entities/{seeded_db['own_corp_id']}/related", + headers=headers, + ) + assert own.status_code == 200, own.text + own_ids = {node["node_id"] for node in own.json()["related"]} + assert seeded_db["other_private_post_id"] not in own_ids + + hidden = client.get( + f"/api/corporate-entities/{seeded_db['other_corp_id']}/related", + headers=headers, + ) + assert hidden.status_code == 403 + + def test_thread_group_run_list_honors_knowledge_cutoff( client, demo_analyst_token, seeded_db ) -> None: diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index d95e2c91..e394d376 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -24,6 +24,7 @@ COPY migrations/0015_organization_name_resolution.sql /docker-entrypoint-initdb. COPY migrations/0016_cross_post_actor_identity.sql /docker-entrypoint-initdb.d/17-cross-post-actor-identity.sql COPY migrations/0017_prov_o_standard_relations.sql /docker-entrypoint-initdb.d/18-prov-o-standard-relations.sql COPY migrations/0018_analysis_run_registry.sql /docker-entrypoint-initdb.d/19-analysis-run-registry.sql +COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-role-catalog-identity.sql # Official image already drops to this account at runtime; declare it so # the Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER postgres diff --git a/docs/adr/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md index 17e4236a..ae0a1c33 100644 --- a/docs/adr/0018-related-nodes-team-org-walk.md +++ b/docs/adr/0018-related-nodes-team-org-walk.md @@ -34,8 +34,9 @@ rows with person-affiliation posts so an org-only mention can start a walk. The summary payload exposes `catalog_node_id` / `catalog_node_type_code` -when the R&R actor resolved to a team or organization mention on that -post. The popup turns that name into a related-node button. +from the catalog foreign keys stored on `post_summary_role` (ADR 0019). +The popup turns that name into a related-node button. Do not reconstruct +the id by `corporate_entity.entity_name`. Thread-group run list visibility requires at least one ABAC-visible `source_post` whose `created_at` is at or before `knowledge_cutoff`. diff --git a/docs/adr/0019-role-catalog-identity.md b/docs/adr/0019-role-catalog-identity.md new file mode 100644 index 00000000..32b5d0a0 --- /dev/null +++ b/docs/adr/0019-role-catalog-identity.md @@ -0,0 +1,65 @@ +# ADR 0019 — R&R catalog identity lives on the role row + +**Decision status:** Accepted +**Date:** 2026-08-16 + +## Context + +ADR 0009 writes `post_team_mention` and `post_organization_mention` so a +cataloged team or organization can start a related-node walk. ADR 0018 +exposes `catalog_node_id` on the summary payload by joining those +mentions back to `post_summary_role` through `actor_name`. + +`corporate_entity.entity_name` is not unique. Two catalog rows can share +a display name (different `corporate_entity_code`, different parents). +A fetch join on name therefore: + +- attaches a homonym that this post never resolved, or +- duplicates the role when more than one same-named row exists. + +Mention tables are post-scoped, not role-scoped. They cannot reconstruct +which catalog id was chosen for a specific R&R row. That reconstruction +is a transitive dependency on a non-key attribute, so it is not third +normal form (Codd, 1970; Date, 2019). + +Team identity is already unique on +`(team_name, affiliated_organization_name)`. Organization identity is +not. + +## Decision + +`post_summary_role` stores the resolved catalog foreign keys +(`cataloged_team_id`, `cataloged_corporate_entity_id`) written during +`persist_post_summary`. `fetch_persisted_summary` reads those columns. +It does not join `corporate_entity` by `entity_name`. + +Migration `0019_role_catalog_identity.sql` backfills existing rows from +a post-scoped mention only when the name match is unique on that post. +Two same-named mentions stay unbound rather than guessing. + +## Consequences + +- Open a post whose R&R names an organization that shares a display + name with another catalog row. The button walks the resolved id, not + the homonym. +- Clicking that name still uses `GET /api/corporate-entities/{id}/related` + or `GET /api/teams/{id}/related`. Authz stays person/entity-parity: + a team mentioned only on another corp's private post is 403; an + unknown UUID is 404. + +## References + +Codd, E. F. (1970). A relational model of data for large shared data +banks. *Communications of the ACM, 13*(6), 377–387. +https://doi.org/10.1145/362384.362685 + +Date, C. J. (2019). *Database design and relational theory: Normal forms +and all that jazz* (2nd ed.). Apress. +https://doi.org/10.1007/978-1-4842-5540-7 + +International Organization for Standardization. (2023). *ISO/IEC +11179-1:2023: Information technology—Metadata registries (MDR)—Part 1: +Framework*. + +Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web +Consortium. https://www.w3.org/TR/vocab-org/ diff --git a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md index 4ecb8fe8..fc7ded98 100644 --- a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md +++ b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md @@ -17,3 +17,15 @@ rules* (confirmed 2024; Amendment 1:2022). World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). https://www.w3.org/TR/owl-time/ + +Codd, E. F. (1970). A relational model of data for large shared data +banks. *Communications of the ACM, 13*(6), 377–387. +https://doi.org/10.1145/362384.362685 + +Date, C. J. (2019). *Database design and relational theory: Normal forms +and all that jazz* (2nd ed.). Apress. +https://doi.org/10.1007/978-1-4842-5540-7 + +International Organization for Standardization. (2023). *ISO/IEC +11179-1:2023: Information technology—Metadata registries (MDR)—Part 1: +Framework*. diff --git a/frontend/package.json b/frontend/package.json index 803acd91..fb52f794 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.1", + "version": "0.86.2", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 48bf6e48..efe84890 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.1" +__version__ = "0.86.2" diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index e2877f44..a94b05a0 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -411,6 +411,15 @@ create table post_organization_mention ( primary key (post_id, corporate_entity_id) ); +-- ADR 0019: store the resolved catalog id on the role row itself. +-- corporate_entity.entity_name is not unique, and mention tables are +-- post-scoped, so reconstructing identity by name is not 3NF. +alter table post_summary_role + add column cataloged_team_id uuid references cataloged_team (team_id); +alter table post_summary_role + add column cataloged_corporate_entity_id uuid + references corporate_entity (corporate_entity_id); + -- --------------------------------------------------------------------- -- Knowledge graph: person/company/post nodes, typed edges. The type -- codes (which kind of node, which kind of edge) are real enums and DO diff --git a/migrations/0019_role_catalog_identity.sql b/migrations/0019_role_catalog_identity.sql new file mode 100644 index 00000000..2881be9b --- /dev/null +++ b/migrations/0019_role_catalog_identity.sql @@ -0,0 +1,46 @@ +-- ADR 0019: bind each R&R role to the catalog row resolved for that +-- role. corporate_entity.entity_name is not unique, so a fetch join on +-- name can attach a homonym or duplicate the role. Mention tables are +-- post-scoped, not role-scoped, and cannot reconstruct that binding. + +alter table post_summary_role + add column if not exists cataloged_team_id uuid + references cataloged_team (team_id); + +alter table post_summary_role + add column if not exists cataloged_corporate_entity_id uuid + references corporate_entity (corporate_entity_id); + +-- Teams already have a unique (team_name, affiliated_organization_name) +-- key. Backfill only when that pair was mentioned on the same post. +update post_summary_role as role + set cataloged_team_id = team.team_id + from cataloged_team as team + join post_team_mention as mention + on mention.team_id = team.team_id + where role.actor_type_code = 'prov_team' + and role.cataloged_team_id is null + and mention.post_id = role.post_id + and team.team_name = role.actor_name + and team.affiliated_organization_name + is not distinct from role.affiliated_organization_name; + +-- Organizations: copy a mention only when exactly one mentioned org on +-- that post has this role's actor_name. Two same-named mentions stay +-- unbound rather than guessing. +update post_summary_role role + set cataloged_corporate_entity_id = matched.corporate_entity_id + from ( + select mention.post_id, + org.entity_name, + min(org.corporate_entity_id) as corporate_entity_id + from post_organization_mention mention + join corporate_entity org + on org.corporate_entity_id = mention.corporate_entity_id + group by mention.post_id, org.entity_name + having count(*) = 1 + ) matched + where role.actor_type_code = 'prov_organization' + and role.cataloged_corporate_entity_id is null + and role.post_id = matched.post_id + and role.actor_name = matched.entity_name; diff --git a/migrations/rollback/0019_role_catalog_identity.sql b/migrations/rollback/0019_role_catalog_identity.sql new file mode 100644 index 00000000..5efafed8 --- /dev/null +++ b/migrations/rollback/0019_role_catalog_identity.sql @@ -0,0 +1,8 @@ +-- Drop role-scoped catalog identity columns added by 0019. +-- Mention tables remain; only the role-row binding is removed. + +alter table post_summary_role + drop column if exists cataloged_team_id; + +alter table post_summary_role + drop column if exists cataloged_corporate_entity_id; diff --git a/pyproject.toml b/pyproject.toml index eb2e2631..6e41c7ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.1" +version = "0.86.2" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index f6f575cc..9d246445 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -120,6 +120,7 @@ def seed( cur.execute((migrations / "0015_organization_name_resolution.sql").read_text()) cur.execute((migrations / "0016_cross_post_actor_identity.sql").read_text()) cur.execute((migrations / "0018_analysis_run_registry.sql").read_text()) + cur.execute((migrations / "0019_role_catalog_identity.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values diff --git a/tests/test_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py index 641a43cc..d2994e2c 100644 --- a/tests/test_ingestion_transaction_contracts.py +++ b/tests/test_ingestion_transaction_contracts.py @@ -209,12 +209,16 @@ async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]: if "from post_summary_event" in compact: return [{"event_text": "검토 완료"}] if "from post_summary_role" in compact: + assert "entity_name" not in compact + assert "cataloged_corporate_entity_id" in compact return [ { "actor_name": "Synthetic Design Team", "responsibility": "도면 검토", "actor_type_code": ACTOR_TYPE_TEAM, "affiliated_organization_name": "Synthetic Energy", + "cataloged_team_id": None, + "cataloged_corporate_entity_id": None, } ] raise AssertionError(f"unexpected fetch query: {compact}") @@ -354,6 +358,14 @@ async def persist_edges(conn, post_id) -> list[Any]: and event[0] == "execute" and "insert into post_organization_mention" in event[1] ) + role_insert = next( + event[1] + for event in events + if isinstance(event, tuple) + and event[0] == "execute" + and "insert into post_summary_role" in event[1] + ) + assert "cataloged_corporate_entity_id" in role_insert assert resolve_index < enter_index < mention_index < exit_index @@ -469,3 +481,29 @@ def test_release_notes_describe_balanced_outer_emphasis_stripping() -> None: assert "strips balanced outer Markdown emphasis from field values" in content assert "while still accepting emphasized field labels" in content assert "preserves Markdown emphasis in field values" not in content + + +def test_role_catalog_identity_is_stored_on_the_role_row() -> None: + """ADR 0019: fetch must not reconstruct organization identity by name.""" + root = Path(__file__).resolve().parents[1] + fetch_source = ( + root / "backend" / "app" / "post_summary_ingestion.py" + ).read_text(encoding="utf-8") + initial = (root / "migrations" / "0001_initial_schema.sql").read_text( + encoding="utf-8" + ) + upgrade = (root / "migrations" / "0019_role_catalog_identity.sql").read_text( + encoding="utf-8" + ) + dockerfile = ( + root / "docker" / "postgres-init" / "Dockerfile" + ).read_text(encoding="utf-8") + changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8") + fetch_sql = fetch_source.split("async def fetch_persisted_summary", 1)[1] + fetch_sql = fetch_sql.split("async def persist_post_summary", 1)[0] + assert "org.entity_name = role.actor_name" not in fetch_sql + assert "cataloged_corporate_entity_id" in fetch_sql + assert "cataloged_team_id" in initial + assert "cataloged_corporate_entity_id" in upgrade + assert "0019_role_catalog_identity.sql" in dockerfile + assert "ADR 0019" in changelog diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py index 5353c2a9..81e63a75 100644 --- a/tests/test_person_mention_projection.py +++ b/tests/test_person_mention_projection.py @@ -27,16 +27,25 @@ related_for_start, visible_mention_post_ids, ) -from backend.app.post_summary_ingestion import persist_post_summary +from backend.app import post_summary_ingestion as summary_ingestion +from backend.app.post_summary_ingestion import ( + fetch_persisted_summary, + persist_post_summary, +) from lineageweave.keyman_extraction import OUR_SIDE, PersonMention from lineageweave.knowledge_graph import ( EDGE_MENTION, EDGE_MENTION_TEAM, + NODE_CORPORATE_ENTITY, NODE_PERSON, NODE_POST, NODE_TEAM, ) -from lineageweave.post_summary import PostSummary, RoleResponsibility +from lineageweave.post_summary import ( + ACTOR_TYPE_ORGANIZATION, + PostSummary, + RoleResponsibility, +) _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" @@ -451,3 +460,85 @@ def test_team_only_posts_walk_related_nodes(projection_database: str) -> None: database_dsn, post_id, _summary_person_id = projection_database.split("|") asyncio.run(_exercise_team_only_related_walk(database_dsn, post_id)) + + +async def _exercise_homonym_organization_role_binding( + database_dsn: str, + post_id: str, +) -> None: + """A same-named catalog org that this post did not resolve must stay off the role.""" + + connection = await asyncpg.connect(database_dsn) + try: + mentioned_id = str( + await connection.fetchval( + """ + insert into corporate_entity + (corporate_entity_code, entity_name, entity_level_code) + values ('HOMONYM-MENTIONED', 'Homonym Energy', 'company') + returning corporate_entity_id + """ + ) + ) + other_id = str( + await connection.fetchval( + """ + insert into corporate_entity + (corporate_entity_code, entity_name, entity_level_code) + values ('HOMONYM-OTHER', 'Homonym Energy', 'company') + returning corporate_entity_id + """ + ) + ) + + async def resolve_mentioned_organization(*_args, **_kwargs) -> str: + return mentioned_id + + original = summary_ingestion.get_or_create_corporate_entity + summary_ingestion.get_or_create_corporate_entity = resolve_mentioned_organization + try: + payload = await persist_post_summary( + connection, + post_id, + PostSummary( + korean_summary="동명이인 조직이 일정만 확정했다.", + roles_and_responsibilities=( + RoleResponsibility( + actor_name="Homonym Energy", + responsibility="납품 일정 확정", + actor_type_code=ACTOR_TYPE_ORGANIZATION, + ), + ), + ), + ) + finally: + summary_ingestion.get_or_create_corporate_entity = original + + roles = payload["roles_and_responsibilities"] + assert len(roles) == 1 + assert roles[0]["catalog_node_id"] == mentioned_id + assert roles[0]["catalog_node_type_code"] == NODE_CORPORATE_ENTITY + fetched = await fetch_persisted_summary(connection, post_id) + assert fetched is not None + assert fetched["roles_and_responsibilities"][0]["catalog_node_id"] == mentioned_id + assert fetched["roles_and_responsibilities"][0]["catalog_node_id"] != other_id + mention_ids = [ + str(row["corporate_entity_id"]) + for row in await connection.fetch( + "select corporate_entity_id from post_organization_mention " + "where post_id = $1", + post_id, + ) + ] + assert mention_ids == [mentioned_id] + finally: + await connection.close() + + +def test_homonym_organization_role_binds_the_resolved_catalog_id( + projection_database: str, +) -> None: + """ADR 0019: two catalog orgs can share a display name; the role keeps one id.""" + + database_dsn, post_id, _summary_person_id = projection_database.split("|") + asyncio.run(_exercise_homonym_organization_role_binding(database_dsn, post_id)) diff --git a/uv.lock b/uv.lock index c759df26..e1d2860c 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.1" +version = "0.86.2" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 39ed6eb460bb6cbb8d4374ac173d21b9938515e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:25:24 +0900 Subject: [PATCH 115/161] fix(ui): pin pending next-action copy to registered kinds (#148) Pending lineage detail now repeats that reconstruction has not started. Pending TEPP rows no longer reuse the reconstruction sentence. Co-authored-by: Cursor Agent Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 3 +- .../0.86.0-related-nodes-team-org-walk.md | 3 +- CHANGELOG.md | 6 ++ CLAUDE.md | 3 +- docs/adr/0014-authorized-analysis-run-read.md | 3 +- frontend/src/App.test.tsx | 7 ++- frontend/src/App.tsx | 56 +++++++++++++------ 7 files changed, 59 insertions(+), 22 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b66c7cde..3a4d0ac4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -491,7 +491,8 @@ measurement service) so `tepp_not_available` is not mistaken for a calibrated negative result. A failed lineage row tells the operator to retry reconstruction, not to connect TEPP. A failed period-report row tells the operator to rebuild the report. A pending TEPP row -does not claim a calibrated measurement. The +does not claim a calibrated measurement. A pending lineage row +says reconstruction has not started yet. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · diff --git a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md index 4989a054..6e4605d5 100644 --- a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md +++ b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md @@ -1,4 +1,5 @@ Related-node walks include team and organization mention edges. Click an R&R team to open sibling posts. Thread-group run lists honor knowledge_cutoff. Failed period-report rows rebuild the report; a pending TEPP corpus -does not claim a calibrated measurement. +does not claim a calibrated measurement. A pending lineage row says +reconstruction has not started yet. diff --git a/CHANGELOG.md b/CHANGELOG.md index 13aeb02f..d30ae14a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ All notable changes to this project are documented here. Format follows whose body includes a data-URI image shows the picture; Extract Keyman or Ask still runs OCR on that image for search. +### Fixed + +- Opening a Pending lineage run repeats that reconstruction has not + started. Pending next-action copy is pinned to the registered run + kinds, so a Pending TEPP row does not say reconstruction. + ## [0.86.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index a1112758..c5a1828f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,8 @@ theta or a local psychometric substitute. The home list caption stays (ADR 0014). Open a Failed TEPP row, then connect a live TEPP transport. A failed lineage row retries reconstruction -- it does not mention TEPP. A failed period-report row rebuilds the report. A -pending TEPP row does not claim a calibrated measurement. +pending TEPP row does not claim a calibrated measurement. A pending +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). diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 29c074c8..500c2bc2 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -48,7 +48,8 @@ service. A failed lineage row tells the operator to retry reconstruction, not to connect TEPP. A failed period-report row 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 +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 diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 65d25259..60d06c8d 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1772,6 +1772,7 @@ describe("App, authenticated", () => { ).toBeInTheDocument(); expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); }); it("does not tell a succeeded TEPP run to replace Failed", async () => { @@ -1799,7 +1800,11 @@ describe("App, authenticated", () => { expect( await screen.findByRole("heading", { name: "Lineage reconstruction · Pending · Demo Corp" }), ).toBeInTheDocument(); - expect(screen.getByText(/has not started yet/)).toBeInTheDocument(); + expect( + screen.getByText( + "Open this run to confirm which posts it will use. Reconstruction has not started yet.", + ), + ).toBeInTheDocument(); const postCall = fetchMock.mock.calls.find( (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a511f713..d589a364 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1451,28 +1451,48 @@ function analysisRunCaption(run: AnalysisRun): string { } /** - * Next action for a failed run on the home list. + * Next action for a pending or failed run on the home list and detail. * * The machine `failure_code` stays on detail history (ADR 0014). Copy - * is kind-specific so a failed lineage reconstruction is not mistaken - * for a missing TEPP transport. + * is pinned to registered kinds so a pending TEPP row is not mistaken + * for reconstruction, and a failed lineage row is not mistaken for a + * missing TEPP transport. */ function analysisRunNextAction(run: AnalysisRun): string | null { - if (run.status_code === "analysis_status_pending") { - return "Open this run to confirm which posts it will use. Reconstruction has not started yet."; - } - if (run.status_code !== "analysis_status_failed") { - return null; - } - switch (run.run_kind_code) { - 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."; - case "analysis_run_report": - return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; + switch (run.status_code) { + case "analysis_status_pending": + switch (run.run_kind_code) { + case "analysis_run_lineage": + return "Open this run to confirm which posts it will use. Reconstruction has not started yet."; + case "analysis_run_tepp": + return "Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result."; + case "analysis_run_report": + return "Open this run to confirm which posts the period report will use. The report has not been built yet."; + default: { + const unexpected: never = run.run_kind_code; + return unexpected; + } + } + case "analysis_status_failed": + switch (run.run_kind_code) { + 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."; + case "analysis_run_report": + return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; + default: { + const unexpected: never = run.run_kind_code; + return unexpected; + } + } + case "analysis_status_running": + case "analysis_status_succeeded": + case "analysis_status_cancelled": + case null: + return null; default: { - const unexpected: never = run.run_kind_code; + const unexpected: never = run.status_code; return unexpected; } } @@ -1648,6 +1668,7 @@ function AnalysisRunsPanel({ if (runs === null) return

          Loading analysis runs...

          ; const corpusHint = selected ? analysisRunCorpusHint(selected) : null; + const selectedNextAction = selected ? analysisRunNextAction(selected) : null; return (
          @@ -1699,6 +1720,7 @@ function AnalysisRunsPanel({ {selected && (

          {analysisRunCaption(selected)}

          + {selectedNextAction &&

          {selectedNextAction}

          }

          Cutoff {selected.knowledge_cutoff.slice(0, 10)} {" · "} From 69c035bb27cc919f8eef5ec1ff0d819649e9e29a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:33:12 +0900 Subject: [PATCH 116/161] feat: add granted retention purge and Storybook tokens (v0.87.0) * feat: add granted retention purge and Storybook tokens (v0.87.0) Operators empty a run-bearing analysis-run registry only after an unrevoked analysis_run_retention_grant and analysis_run_retention_admin membership (ADR 0020). PUBLIC cannot execute the definer function. Repeated citation chips and close buttons use named design tokens and a Storybook catalog on Node 24. ADR 0019 stays the R&R catalog-id bind. Do not reuse that number. Co-authored-by: Seongho Bae * test: list a created pending lineage run in the home stub After POST /api/analysis-runs the list refetch must include the new Pending row so the buyer-facing "has not started yet" next action is visible. The previous stub kept only the seed rows. Co-authored-by: Seongho Bae * test: expect pending lineage copy on list and detail After #148 the next-action phrase is pinned to registered kinds and shown on both the created list row and the selected detail. Assert both copies so getByText does not fail on the duplicate. Co-authored-by: Seongho Bae --------- Co-authored-by: Cursor Agent Co-authored-by: Seongho Bae --- .github/workflows/tests.yml | 4 + AGENTS.md | 6 + ARCHITECTURE.md | 6 + .../0.87.0-retention-purge-grant-admin.md | 13 + CHANGELOG.md | 17 + CLAUDE.md | 12 + README.md | 6 + backend/tests/test_api.py | 2 + docker/postgres-init/Dockerfile | 1 + .../0013-normalized-analysis-run-registry.md | 7 +- docs/adr/0020-analysis-run-retention-purge.md | 101 + .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 21 +- docs/doctoring/DESIGN_TOKEN_REFERENCES.md | 20 + docs/storybook-inventory.md | 21 + frontend/.gitignore | 1 + frontend/.storybook/main.ts | 12 + frontend/.storybook/preview.ts | 11 + frontend/package.json | 8 +- frontend/pnpm-lock.yaml | 2094 +++++++++++++++-- frontend/src/App.css | 16 +- frontend/src/App.test.tsx | 12 +- frontend/src/App.tsx | 22 +- .../src/components/CitationChip.stories.tsx | 24 + frontend/src/components/CitationChip.test.tsx | 21 + frontend/src/components/CitationChip.tsx | 27 + .../components/PopupCloseButton.stories.tsx | 23 + .../src/components/PopupCloseButton.test.tsx | 17 + frontend/src/components/PopupCloseButton.tsx | 22 + frontend/src/index.css | 34 +- frontend/src/styles/tokens.css | 32 + frontend/tsconfig.app.json | 3 +- lineageweave/__init__.py | 2 +- .../0020_analysis_run_retention_purge.sql | 180 ++ .../rollback/0018_analysis_run_registry.sql | 6 +- .../0020_analysis_run_retention_purge.sql | 33 + pyproject.toml | 2 +- scripts/seed_demo_data.py | 1 + tests/test_analysis_run_authorization.py | 2 + tests/test_analysis_run_registry_schema.py | 424 ++++ uv.lock | 2 +- 40 files changed, 3053 insertions(+), 215 deletions(-) create mode 100644 CHANGELOG.d/0.87.0-retention-purge-grant-admin.md create mode 100644 docs/adr/0020-analysis-run-retention-purge.md create mode 100644 docs/doctoring/DESIGN_TOKEN_REFERENCES.md create mode 100644 docs/storybook-inventory.md create mode 100644 frontend/.storybook/main.ts create mode 100644 frontend/.storybook/preview.ts create mode 100644 frontend/src/components/CitationChip.stories.tsx create mode 100644 frontend/src/components/CitationChip.test.tsx create mode 100644 frontend/src/components/CitationChip.tsx create mode 100644 frontend/src/components/PopupCloseButton.stories.tsx create mode 100644 frontend/src/components/PopupCloseButton.test.tsx create mode 100644 frontend/src/components/PopupCloseButton.tsx create mode 100644 frontend/src/styles/tokens.css create mode 100644 migrations/0020_analysis_run_retention_purge.sql create mode 100644 migrations/rollback/0020_analysis_run_retention_purge.sql diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e78d3625..1cad1f17 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -90,3 +90,7 @@ jobs: - name: Build working-directory: frontend run: pnpm run build + + - name: Build Storybook + working-directory: frontend + run: pnpm run build-storybook diff --git a/AGENTS.md b/AGENTS.md index dba1c4b4..47a71c8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,8 +83,14 @@ floating Node version): ```bash cd frontend && pnpm install pnpm run lint && pnpm run test && pnpm run build +# Storybook inventory (ADR 0020 tokens): pnpm run build-storybook ``` +A run-bearing analysis-run registry empties only after an unrevoked +`analysis_run_retention_grant` and `GRANT analysis_run_retention_admin` +(ADR 0020 / v0.87.0). The documented phrase is not a secret. Do not +expose purge on a public HTTP route. + ## CI gates `.github/workflows/tests.yml` runs the full suite on every PR to `main`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3a4d0ac4..bf19c5f7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -499,6 +499,12 @@ Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · Demo Corp" with "3 documents" and Pending / Running / Succeeded times, and "TEPP measurement · Failed · Demo Corp" whose detail history ends in Failed / `tepp_not_available`. +A run-bearing registry is emptied only after an unrevoked +`analysis_run_retention_grant` and `GRANT analysis_run_retention_admin`, +then `purge_analysis_run_registry('approved-retention-purge')` +(ADR 0020); a raw `DELETE` and a runtime role that only knows the +public phrase stay rejected. Repeated chip and close controls use +`frontend/src/styles/tokens.css` and the Storybook inventory. ## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only) diff --git a/CHANGELOG.d/0.87.0-retention-purge-grant-admin.md b/CHANGELOG.d/0.87.0-retention-purge-grant-admin.md new file mode 100644 index 00000000..a2bcb7d8 --- /dev/null +++ b/CHANGELOG.d/0.87.0-retention-purge-grant-admin.md @@ -0,0 +1,13 @@ +# 0.87.0 analysis-run retention purge + +Operators empty a run-bearing analysis-run registry only after an +unrevoked `analysis_run_retention_grant` and +`GRANT analysis_run_retention_admin`. Then +`select purge_analysis_run_registry('approved-retention-purge')`. +Export `analysis_run_retention_event`, delete those rows, then roll +back 0020 and 0018. A raw DELETE, a published token without a grant, +and a runtime role that is not the admin role still fail (ADR 0020). + +Designers can change chip and close-button appearance in +`frontend/src/styles/tokens.css` and preview the next click in Storybook +(`cd frontend && pnpm run storybook`). diff --git a/CHANGELOG.md b/CHANGELOG.md index d30ae14a..00a19fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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.0] - 2026-08-16 + +### Added + +- Operators can empty a run-bearing analysis-run registry without a + superuser trigger disable. Insert an unrevoked + `analysis_run_retention_grant` for `session_user`, grant + `analysis_run_retention_admin`, then + `select purge_analysis_run_registry('approved-retention-purge')` + (ADR 0020). Export `analysis_run_retention_event`, delete those + rows, then roll back 0020 and 0018. A raw `DELETE`, a published + token without a grant, and a runtime role that is not the admin + role still fail. +- Repeated citation chips and close buttons use named design tokens + in `frontend/src/styles/tokens.css`. Preview them in Storybook + (`cd frontend && pnpm run storybook`). + ## [0.86.2] - 2026-08-16 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index c5a1828f..870c77f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,18 @@ Tool-specific pointer. Policy lives in [AGENTS.md](AGENTS.md) and the ADRs under `docs/adr/`. Do not fork those rules here. +## Analysis-run retention (v0.87.0) + +To empty a run-bearing registry, insert an unrevoked +`analysis_run_retention_grant` for `session_user` and +`GRANT analysis_run_retention_admin` (ADR 0020). Then +`select purge_analysis_run_registry('approved-retention-purge')`, +export `analysis_run_retention_event`, delete those rows, and roll +back 0020 then 0018. The published phrase is not a secret. Do not +`DISABLE TRIGGER` as superuser. Do not grant the admin role or a +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) `make seed` writes a Demo Corp lineage run and a TEPP run on the same diff --git a/README.md b/README.md index 2f963c7f..b6a881a3 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,12 @@ FastAPI backend over real `fetch()` with the token Keycloak issued. make up make seed cd frontend && cp .env.example .env.local && pnpm install && pnpm run dev +# Repeated chip/close controls: pnpm run storybook +# (Node 24 via frontend/mise.toml; pnpm only) +# Empty a run-bearing registry: insert analysis_run_retention_grant +# for session_user, GRANT analysis_run_retention_admin, then +# select purge_analysis_run_registry('approved-retention-purge'). +# The published token is not a grant (ADR 0020). # -> http://localhost:5173, click "Log in", redirects through the real # Keycloak login page for demo.analyst / lineageweave-demo-only ``` diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 21c71bc9..3b74c22a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -32,6 +32,7 @@ _REALM = "lineageweave-demo" _MIGRATION_PATH = Path(__file__).resolve().parents[2] / "migrations" / "0001_initial_schema.sql" _REGISTRY_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0018_analysis_run_registry.sql" +_RETENTION_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0020_analysis_run_retention_purge.sql" def _postgres_available() -> bool: @@ -115,6 +116,7 @@ def seeded_db(demo_analyst_token): with conn.cursor() as cur: cur.execute(_MIGRATION_PATH.read_text()) cur.execute(_REGISTRY_MIGRATION.read_text()) + cur.execute(_RETENTION_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index e394d376..ce2f0e6b 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -25,6 +25,7 @@ COPY migrations/0016_cross_post_actor_identity.sql /docker-entrypoint-initdb.d/1 COPY migrations/0017_prov_o_standard_relations.sql /docker-entrypoint-initdb.d/18-prov-o-standard-relations.sql COPY migrations/0018_analysis_run_registry.sql /docker-entrypoint-initdb.d/19-analysis-run-registry.sql COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-role-catalog-identity.sql +COPY migrations/0020_analysis_run_retention_purge.sql /docker-entrypoint-initdb.d/21-analysis-run-retention-purge.sql # Official image already drops to this account at runtime; declare it so # the Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER postgres diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index b60164dc..d13994bc 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -209,9 +209,10 @@ functional dependency and forces duplicate snapshots. Migration replay is idempotent and rejects lookup-category collisions. The rollback refuses to remove non-empty registry relations. Evidence must first be -exported or explicitly deleted under an approved retention procedure. An empty -rollback removes the view, tables, functions, and lookup rows and is itself -replayable. +exported, then emptied with `purge_analysis_run_registry` after an unrevoked +`analysis_run_retention_grant` and `analysis_run_retention_admin` membership +(ADR 0020). An empty rollback removes the view, tables, functions, and lookup +rows and is itself replayable. ## Verification diff --git a/docs/adr/0020-analysis-run-retention-purge.md b/docs/adr/0020-analysis-run-retention-purge.md new file mode 100644 index 00000000..928ba87c --- /dev/null +++ b/docs/adr/0020-analysis-run-retention-purge.md @@ -0,0 +1,101 @@ +# ADR 0020 — Approved retention purge requires a session grant and admin role + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-16 +**Depends on:** ADR 0013 normalized analysis-run registry + +## Context + +ADR 0013 and migration `0018` make `analysis_run`, `analysis_run_scope`, and +`analysis_run_status_event` immutable. The 0018 rollback refuses to drop +non-empty registry relations and tells operators to export or delete evidence +under an approved retention procedure. + +After the first `analysis_run` insert, a raw `DELETE` is rejected. Snapshot +delete is then blocked by the foreign key. Operators following the documented +procedure cannot satisfy `analysis_run_registry_not_empty` without a superuser +`DISABLE TRIGGER`. That is not a supported product path (ISO 15489-1:2016 +disposition; NIST SP 800-92 protected audit records). + +A `SECURITY DEFINER` function that `PUBLIC` can execute, or that accepts only +a documented phrase, lets any SQL session wipe analysis-run evidence +(NIST SP 800-53 Rev. 5 AC-3; CWE-250). The phrase is a procedure name, not +an authorization secret. The write API is a separate slice; SQL operators +still need a grant that is independent of application `user_account` rows. + +Landed #122 occupies ADR 0018 / package 0.86.0 for the team and organization +related-node walk. ADR 0019 binds `cataloged_team_id` / +`cataloged_corporate_entity_id` on `post_summary_role` and must not be +reused here. This decision is the next free slot. + +## Decision + +Migration `0020_analysis_run_retention_purge.sql` adds a conjunctive +fail-closed purge: + +- `analysis_run_retention_grant` — one unrevoked row per + `database_role_name`; history of revoked grants is allowed; +- `analysis_run_retention_admin` — `NOLOGIN` role that receives + `EXECUTE`; `PUBLIC` does not; +- `purge_analysis_run_registry(approval_token text)` — `SECURITY DEFINER`, + checks the unrevoked grant, then `pg_has_role(..., 'member')` on the + admin role, then accepts only `approved-retention-purge`, disables the + three immutability delete triggers inside that call, deletes in FK + order, re-enables the triggers, and writes one + `analysis_run_retention_event`; +- `analysis_run_retention_event` — purged run/snapshot counts, the SHA-256 + of the approval token, `invoking_session_role`, `invoking_current_role`, + and optional `client_network_address`. The raw phrase is never stored. + +A session `SET` cannot authorize a raw `DELETE`. A table-DML runtime role +that only knows the public phrase cannot call the function. A member of +the admin role without a grant cannot purge. A grant without admin +membership cannot purge. After purge, export the retention event, delete +those rows, roll back 0020, then roll back 0018. + +This migration does not insert a grant or grant the admin role to the +migrator. Production `DATABASE_URL` must not be a superuser and must not +hold either privilege. + +## Consequences + +- A run-bearing registry can be emptied without superuser trigger disable. +- Retention remains an explicit, audited operator action, not a silent + downgrade. +- 0018 rollback stays fail-closed until the registry tables are empty; + 0020 rollback stays fail-closed until retention events are exported + and deleted. +- Repeated citation-chip and close-button appearance lives in + `frontend/src/styles/tokens.css` and the Storybook inventory. + +## Follow-up + +When the authorized write API exists, bind an administrator +`user_account` to the same grant table. Keep the SQL-role grant for +operators who purge from `psql`. Do not expose purge on a public HTTP +route. Split the application login from the migration owner so the +product role cannot execute the function even as table owner. + +## References — APA 7th + +American Institute of Certified Public Accountants. (2017). *SOC 2®: SOC +for Service Organizations: Trust Services Criteria*. + +International Organization for Standardization. (2016). *ISO 15489-1:2016: +Information and documentation—Records management—Part 1: Concepts and +principles*. + +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 + +MITRE. (2026). *CWE-250: Execution with unnecessary privileges*. +https://cwe.mitre.org/data/definitions/250.html + +National Institute of Standards and Technology. (2020). *Security and +privacy controls for information systems and organizations* (NIST Special +Publication 800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: +5.8. Privileges*. +https://www.postgresql.org/docs/current/ddl-priv.html diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index b41b31c1..c776053b 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -1,7 +1,7 @@ # Analysis-run registry standards and research traceability **Status:** Active PR evidence; not protected-main truth until merge. -**Scope:** Migration 0018, ADR 0013, rollback, and real-PostgreSQL contract tests. +**Scope:** Migrations 0018 and 0020, ADR 0013 / 0020, rollback, and real-PostgreSQL contract tests. ## Standards mapped to implementation @@ -12,7 +12,8 @@ | W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. | | ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | | PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. | -| 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. | +| 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. | ## Temporal reasoning @@ -75,10 +76,17 @@ provenance, retention, and immutable evidence rather than blanket masking. | Request identity is stable | Reject analysis-run updates; scope and lifecycle live in their own relations. | | Idempotency is actor-scoped | Permit identical opaque keys for two accounts and reject reuse by the same account. | | Lifecycle is ordered | Require pending first, contiguous ordinals, monotonic time, legal transitions, terminal finality, and append-only rows. | -| Rollback does not erase audit data silently | Reject rollback with any registry rows and allow replay after explicit cleanup. | +| Rollback does not erase audit data silently | Reject 0018 rollback with any registry rows. A run-bearing registry empties only through an unrevoked `analysis_run_retention_grant` plus `analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')`; a wrong token, a raw `DELETE`, and a runtime role that only knows the public phrase stay rejected. Export then delete `analysis_run_retention_event` before 0020 rollback. | ## APA 7th references +American Institute of Certified Public Accountants. (2017). *SOC 2®: SOC +for Service Organizations: Trust Services Criteria*. + +International Organization for Standardization. (2016). *ISO 15489-1:2016: +Information and documentation—Records management—Part 1: Concepts and +principles*. + International Organization for Standardization. (2019). *ISO 8601-1:2019: Date and time—Representations for information interchange—Part 1: Basic rules* (confirmed 2024; Amendment 1:2022). @@ -87,6 +95,10 @@ 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 +National Institute of Standards and Technology. (2020). *Security and +privacy controls for information systems and organizations* (NIST Special +Publication 800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 + Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ @@ -96,6 +108,9 @@ https://spec.openapis.org/oas/v3.2.0.html PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: 5.5. Constraints*. https://www.postgresql.org/docs/current/ddl-constraints.html +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: +5.8. Privileges*. https://www.postgresql.org/docs/current/ddl-priv.html + World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C Recommendation). https://www.w3.org/TR/prov-o/ diff --git a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md new file mode 100644 index 00000000..2f0647dc --- /dev/null +++ b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md @@ -0,0 +1,20 @@ +# Design-token and Storybook traceability + +**Status:** Active PR evidence; not protected-main truth until merge. +**Scope:** `frontend/src/styles/tokens.css`, repeated chip/close modules, and +the Storybook inventory. + +## Standards mapped to implementation + +| 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`. | +| 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 + +Design Tokens Community Group. (2025). *Design Tokens Format Module 1.0* +(W3C Community Group Draft Report). https://tr.designtokens.org/format/ + +Storybook. (2026). *Storybook for React & Vite*. +https://storybook.js.org/docs/get-started/frameworks/react-vite diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md new file mode 100644 index 00000000..282e3515 --- /dev/null +++ b/docs/storybook-inventory.md @@ -0,0 +1,21 @@ +# Storybook inventory + +Open the catalog after `cd frontend && pnpm run storybook`. Each story is a +buyer-facing control you can click before changing product CSS. + +| Story | Buyer next action | Token / module | +|---|---|---| +| `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` | + +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; +Storybook is installed with the existing pnpm pin on Node 24. + +## References — APA 7th + +Design Tokens Community Group. (2025). *Design Tokens Format Module 1.0* +(W3C Community Group Draft Report). https://tr.designtokens.org/format/ + +Storybook. (2026). *Storybook for React & Vite*. +https://storybook.js.org/docs/get-started/frameworks/react-vite diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf36..87b58f06 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +storybook-static *.local # Editor directories and files diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 00000000..d123813d --- /dev/null +++ b/frontend/.storybook/main.ts @@ -0,0 +1,12 @@ +import type { StorybookConfig } from "@storybook/react-vite"; + +const config: StorybookConfig = { + stories: ["../src/**/*.stories.@(ts|tsx)"], + addons: [], + framework: { + name: "@storybook/react-vite", + options: {}, + }, +}; + +export default config; diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts new file mode 100644 index 00000000..4907f91d --- /dev/null +++ b/frontend/.storybook/preview.ts @@ -0,0 +1,11 @@ +import type { Preview } from "@storybook/react-vite"; +import "../src/index.css"; +import "../src/App.css"; + +const preview: Preview = { + parameters: { + controls: { matchers: { color: /(background|color)$/i } }, + }, +}; + +export default preview; diff --git a/frontend/package.json b/frontend/package.json index fb52f794..0d43d9fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,14 +1,16 @@ { "name": "frontend", "private": true, - "version": "0.86.2", + "version": "0.87.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "dependencies": { "oidc-client-ts": "^3.5.0", @@ -17,6 +19,7 @@ "react-oidc-context": "^3.3.1" }, "devDependencies": { + "@storybook/react-vite": "^10.5.8", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.4", @@ -26,6 +29,7 @@ "@vitejs/plugin-react": "^6.0.4", "jsdom": "^30.0.1", "oxlint": "^1.75.0", + "storybook": "^10.5.8", "typescript": "~6.0.2", "vite": "^8.2.0", "vitest": "^4.1.10" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d82b64db..a3f53c14 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -21,9 +21,12 @@ importers: specifier: ^3.3.1 version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.8) devDependencies: + '@storybook/react-vite': + specifier: ^10.5.8 + version: 10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) '@testing-library/jest-dom': specifier: ^7.0.1 - version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3))) + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))) '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -41,22 +44,25 @@ importers: version: 19.2.4(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^6.0.4 - version: 6.0.5(vite@8.2.1(@types/node@24.13.3)) + version: 6.0.5(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) jsdom: specifier: ^30.0.1 version: 30.0.1 oxlint: specifier: ^1.75.0 version: 1.78.0 + storybook: + specifier: ^10.5.8 + version: 10.5.8(@types/react@19.2.18)(react@19.2.8) typescript: specifier: ~6.0.2 version: 6.0.3 vite: specifier: ^8.2.0 - version: 8.2.1(@types/node@24.13.3) + version: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)) + version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) packages: @@ -75,14 +81,73 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -123,6 +188,180 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@exodus/bytes@1.15.1': resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -132,219 +371,553 @@ packages: '@noble/hashes': optional: true + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0': + resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==} + peerDependencies: + typescript: '>= 4.3.x' + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@oxc-project/types@0.144.0': - resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@oxlint/binding-android-arm-eabi@1.78.0': - resolution: {integrity: sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==} + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@oxc-parser/binding-android-arm-eabi@0.127.0': + resolution: {integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.78.0': - resolution: {integrity: sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==} + '@oxc-parser/binding-android-arm64@0.127.0': + resolution: {integrity: sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.78.0': - resolution: {integrity: sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==} + '@oxc-parser/binding-darwin-arm64@0.127.0': + resolution: {integrity: sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.78.0': - resolution: {integrity: sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==} + '@oxc-parser/binding-darwin-x64@0.127.0': + resolution: {integrity: sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.78.0': - resolution: {integrity: sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==} + '@oxc-parser/binding-freebsd-x64@0.127.0': + resolution: {integrity: sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.78.0': - resolution: {integrity: sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': + resolution: {integrity: sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.78.0': - resolution: {integrity: sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==} + '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': + resolution: {integrity: sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.78.0': - resolution: {integrity: sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==} + '@oxc-parser/binding-linux-arm64-gnu@0.127.0': + resolution: {integrity: sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-arm64-musl@1.78.0': - resolution: {integrity: sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==} + '@oxc-parser/binding-linux-arm64-musl@0.127.0': + resolution: {integrity: sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-ppc64-gnu@1.78.0': - resolution: {integrity: sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==} + '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': + resolution: {integrity: sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxlint/binding-linux-riscv64-gnu@1.78.0': - resolution: {integrity: sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==} + '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': + resolution: {integrity: sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-riscv64-musl@1.78.0': - resolution: {integrity: sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==} + '@oxc-parser/binding-linux-riscv64-musl@0.127.0': + resolution: {integrity: sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-s390x-gnu@1.78.0': - resolution: {integrity: sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==} + '@oxc-parser/binding-linux-s390x-gnu@0.127.0': + resolution: {integrity: sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxlint/binding-linux-x64-gnu@1.78.0': - resolution: {integrity: sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==} + '@oxc-parser/binding-linux-x64-gnu@0.127.0': + resolution: {integrity: sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-linux-x64-musl@1.78.0': - resolution: {integrity: sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==} + '@oxc-parser/binding-linux-x64-musl@0.127.0': + resolution: {integrity: sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-openharmony-arm64@1.78.0': - resolution: {integrity: sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==} + '@oxc-parser/binding-openharmony-arm64@0.127.0': + resolution: {integrity: sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.78.0': - resolution: {integrity: sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==} + '@oxc-parser/binding-wasm32-wasi@0.127.0': + resolution: {integrity: sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.127.0': + resolution: {integrity: sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.78.0': - resolution: {integrity: sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==} + '@oxc-parser/binding-win32-ia32-msvc@0.127.0': + resolution: {integrity: sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.78.0': - resolution: {integrity: sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==} + '@oxc-parser/binding-win32-x64-msvc@0.127.0': + resolution: {integrity: sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/binding-android-arm64@1.2.4': - resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@oxc-project/types@0.144.0': + resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.4': - resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.4': - resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.4': - resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.4': - resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.4': - resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.2.4': - resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-ppc64-gnu@1.2.4': - resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} cpu: [ppc64] os: [linux] - '@rolldown/binding-linux-s390x-gnu@1.2.4': - resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} cpu: [s390x] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.2.4': - resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.2.4': - resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.2.4': - resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.4': - resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.4': - resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==} - engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.1': - resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@oxlint/binding-android-arm-eabi@1.78.0': + resolution: {integrity: sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} - engines: {node: '>=18'} + '@oxlint/binding-android-arm64@1.78.0': + resolution: {integrity: sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.78.0': + resolution: {integrity: sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.78.0': + resolution: {integrity: sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.78.0': + resolution: {integrity: sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.78.0': + resolution: {integrity: sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.78.0': + resolution: {integrity: sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.78.0': + resolution: {integrity: sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.78.0': + resolution: {integrity: sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.78.0': + resolution: {integrity: sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.78.0': + resolution: {integrity: sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.78.0': + resolution: {integrity: sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.78.0': + resolution: {integrity: sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.78.0': + resolution: {integrity: sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.78.0': + resolution: {integrity: sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.78.0': + resolution: {integrity: sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.78.0': + resolution: {integrity: sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.78.0': + resolution: {integrity: sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.78.0': + resolution: {integrity: sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-android-arm64@1.2.4': + resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.4': + resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.4': + resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.4': + resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.4': + resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.4': + resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.4': + resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.4': + resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.4': + resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@storybook/builder-vite@10.5.8': + resolution: {integrity: sha512-UeRnn7yT55WmBlHNOQzLrvN7vsHEvVgIukhKDO+4cMbGXN87wZkbxhx6NstpuXRH8OxGqwKS0SZNVp+SC1ftLQ==} + peerDependencies: + storybook: ^10.5.8 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@storybook/csf-plugin@10.5.8': + resolution: {integrity: sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==} + peerDependencies: + esbuild: '*' + rollup: '*' + storybook: ^10.5.8 + vite: '*' + webpack: '*' + peerDependenciesMeta: + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + + '@storybook/global@5.0.0': + resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} + + '@storybook/icons@2.1.0': + resolution: {integrity: sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@storybook/react-dom-shim@10.5.8': + resolution: {integrity: sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.5.8 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@storybook/react-vite@10.5.8': + resolution: {integrity: sha512-ioMJGi4YzueGsJBlYio+2+UhfCFB9QV5Bs1lOilkek+a4BZgKJl0D1mVSJl6k96stQBPZmLgI9/l0hLVcUL6Kg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.5.8 + typescript: '>= 4.9.x' + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@storybook/react@10.5.8': + resolution: {integrity: sha512-6qqkmqX6imtL+0Z9Uan2tIfYivOI0FiVmWr0zpqqQR15AkJ18JfNcNTQoyjeAlCO0Kei56SWqnu2qLq52TYplg==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.5.8 + typescript: '>= 4.9.x' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + typescript: + optional: true + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} '@testing-library/jest-dom@7.0.1': resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} @@ -377,15 +950,33 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/doctrine@0.0.9': + resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -400,6 +991,9 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/resolve@1.20.6': + resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} + '@vitejs/plugin-react@6.0.5': resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -413,6 +1007,9 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -427,6 +1024,9 @@ packages: vite: optional: true + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.1.10': resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} @@ -436,12 +1036,26 @@ packages: '@vitest/snapshot@4.1.10': resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.1.10': resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@webcontainer/env@1.1.1': + resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -461,13 +1075,50 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.14: + resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==} + engines: {node: '>=6.0.0'} + hasBin: true + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -485,9 +1136,34 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -496,22 +1172,58 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + electron-to-chromium@1.5.407: + resolution: {integrity: sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==} + + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -530,6 +1242,21 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -538,9 +1265,27 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -553,6 +1298,19 @@ packages: canvas: optional: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jwt-decode@4.0.0: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} @@ -627,10 +1385,16 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -645,11 +1409,29 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -658,6 +1440,17 @@ packages: resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==} engines: {node: '>=18'} + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + oxc-parser@0.127.0: + resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + oxlint@1.78.0: resolution: {integrity: sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -674,9 +1467,20 @@ packages: parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -696,6 +1500,15 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + react-docgen-typescript@2.4.0: + resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} + peerDependencies: + typescript: '>= 4.3.x' + + react-docgen@8.0.3: + resolution: {integrity: sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==} + engines: {node: ^20.9.0 || >=22} + react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: @@ -715,6 +1528,10 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + recast@0.23.21: + resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} + engines: {node: '>= 4'} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -723,11 +1540,20 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + rolldown@1.2.4: resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -735,6 +1561,15 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -742,19 +1577,53 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + storybook@10.5.8: + resolution: {integrity: sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==} + hasBin: true + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + prettier: ^2 || ^3 + vite-plus: ^0.1.15 || ^0.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + prettier: + optional: true + vite-plus: + optional: true + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -766,10 +1635,18 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.1.1: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + tldts-core@7.4.10: resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} @@ -785,6 +1662,17 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -797,6 +1685,21 @@ packages: resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vite@8.2.1: resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -889,6 +1792,9 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@5.0.0: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} @@ -897,84 +1803,471 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - whatwg-url@17.1.0: - resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} - engines: {node: ^22.14.0 || >=24.0.0} + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@exodus/bytes@1.15.1': {} + + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))': + dependencies: + glob: 13.0.6 + react-docgen-typescript: 2.4.0(typescript@6.0.3) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2) + optionalDependencies: + typescript: 6.0.3 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-parser/binding-android-arm-eabi@0.127.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.127.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.127.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.127.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.127.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.127.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.127.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.127.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.127.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.127.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.127.0': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.127.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.127.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.127.0': + optional: true + + '@oxc-project/types@0.127.0': {} - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true + '@oxc-project/types@0.144.0': {} - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true -snapshots: + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true - '@adobe/css-tools@4.5.0': {} + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true - '@asamuzakjp/css-color@6.0.7': - dependencies: - '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - lru-cache: 11.5.2 + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true - '@asamuzakjp/dom-selector@8.3.2': - dependencies: - bidi-js: 1.0.3 - css-tree: 3.2.1 - is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.2 + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true - '@babel/code-frame@7.29.7': - dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true - '@babel/helper-validator-identifier@7.29.7': {} + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true - '@babel/runtime@7.29.7': {} + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true - '@bramus/specificity@2.4.2': - dependencies: - css-tree: 3.2.1 + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true - '@csstools/color-helpers@6.1.0': {} + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true - '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': - dependencies: - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true - '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': - dependencies: - '@csstools/color-helpers': 6.1.0 - '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true - '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': - dependencies: - '@csstools/css-tokenizer': 4.0.0 + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true - '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': - optionalDependencies: - css-tree: 3.2.1 + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true - '@csstools/css-tokenizer@4.0.0': {} + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true - '@exodus/bytes@1.15.1': {} + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true - '@jridgewell/sourcemap-codec@1.5.5': {} + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true - '@oxc-project/types@0.144.0': {} + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true '@oxlint/binding-android-arm-eabi@1.78.0': optional: true @@ -1077,8 +2370,89 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@rollup/pluginutils@5.4.0': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.5 + '@standard-schema/spec@1.1.0': {} + '@storybook/builder-vite@10.5.8(esbuild@0.28.2)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))': + dependencies: + '@storybook/csf-plugin': 10.5.8(esbuild@0.28.2)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) + storybook: 10.5.8(@types/react@19.2.18)(react@19.2.8) + ts-dedent: 2.3.0 + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2) + transitivePeerDependencies: + - esbuild + - rollup + - webpack + + '@storybook/csf-plugin@10.5.8(esbuild@0.28.2)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))': + dependencies: + storybook: 10.5.8(@types/react@19.2.18)(react@19.2.8) + unplugin: 2.3.11 + optionalDependencies: + esbuild: 0.28.2 + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2) + + '@storybook/global@5.0.0': {} + + '@storybook/icons@2.1.0(react@19.2.8)': + dependencies: + react: 19.2.8 + + '@storybook/react-dom-shim@10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))': + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.8(@types/react@19.2.18)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@storybook/react-vite@10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))': + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) + '@rollup/pluginutils': 5.4.0 + '@storybook/builder-vite': 10.5.8(esbuild@0.28.2)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) + '@storybook/react': 10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(typescript@6.0.3) + empathic: 2.0.1 + magic-string: 0.30.21 + react: 19.2.8 + react-docgen: 8.0.3 + react-dom: 19.2.8(react@19.2.8) + resolve: 1.22.12 + storybook: 10.5.8(@types/react@19.2.18)(react@19.2.8) + tsconfig-paths: 4.2.0 + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - esbuild + - rollup + - supports-color + - webpack + + '@storybook/react@10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(typescript@6.0.3)': + dependencies: + '@storybook/global': 5.0.0 + '@storybook/react-dom-shim': 10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8)) + react: 19.2.8 + react-docgen: 8.0.3 + react-docgen-typescript: 2.4.0(typescript@6.0.3) + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.8(@types/react@19.2.18)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -1090,7 +2464,16 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)))': + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)))': dependencies: '@adobe/css-tools': 4.5.0 '@testing-library/dom': 10.4.1 @@ -1100,7 +2483,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)) + vitest: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: @@ -1116,8 +2499,34 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1125,6 +2534,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/doctrine@0.0.9': {} + '@types/estree@1.0.9': {} '@types/node@24.13.3': @@ -1139,10 +2550,20 @@ snapshots: dependencies: csstype: 3.2.3 - '@vitejs/plugin-react@6.0.5(vite@8.2.1(@types/node@24.13.3))': + '@types/resolve@1.20.6': {} + + '@vitejs/plugin-react@6.0.5(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.1(@types/node@24.13.3) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2) + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 '@vitest/expect@4.1.10': dependencies: @@ -1153,13 +2574,17 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.13.3))': + '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.1(@types/node@24.13.3) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 '@vitest/pretty-format@4.1.10': dependencies: @@ -1177,14 +2602,28 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + '@vitest/spy@4.1.10': {} + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vitest/utils@4.1.10': dependencies: '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + '@webcontainer/env@1.1.1': {} + + acorn@8.18.0: {} + ansi-regex@5.0.1: {} ansi-styles@5.2.0: {} @@ -1197,12 +2636,48 @@ snapshots: assertion-error@2.0.1: {} + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.14: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.14 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.407 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + caniuse-lite@1.0.30001809: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.2: {} + check-error@2.1.3: {} + convert-source-map@2.0.0: {} css-tree@3.2.1: @@ -1221,24 +2696,86 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + debug@4.4.3: + dependencies: + ms: 2.1.3 + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} + electron-to-chromium@1.5.407: {} + + empathic@2.0.1: {} + entities@8.0.0: {} + es-errors@1.3.0: {} + es-module-lexer@2.3.1: {} + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + esprima@4.0.1: {} + + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} fdir@6.5.0(picomatch@4.0.5): @@ -1248,6 +2785,20 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.1 @@ -1256,8 +2807,22 @@ snapshots: indent-string@4.0.0: {} + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-docker@3.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-potential-custom-element-name@1.0.1: {} + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + js-tokens@4.0.0: {} jsdom@30.0.1: @@ -1286,6 +2851,12 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + jsesc@3.1.0: {} + + json5@2.2.3: {} + + jsonc-parser@3.3.1: {} + jwt-decode@4.0.0: {} lightningcss-android-arm64@1.33.0: @@ -1337,8 +2908,14 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + loupe@3.2.1: {} + lru-cache@11.5.2: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + lz-string@1.5.0: {} magic-string@0.30.21: @@ -1349,14 +2926,80 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + ms@2.1.3: {} + nanoid@3.3.18: {} + node-releases@2.0.53: {} + obug@2.1.4: {} oidc-client-ts@3.5.0: dependencies: jwt-decode: 4.0.0 + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + oxc-parser@0.127.0: + dependencies: + '@oxc-project/types': 0.127.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.127.0 + '@oxc-parser/binding-android-arm64': 0.127.0 + '@oxc-parser/binding-darwin-arm64': 0.127.0 + '@oxc-parser/binding-darwin-x64': 0.127.0 + '@oxc-parser/binding-freebsd-x64': 0.127.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.127.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.127.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.127.0 + '@oxc-parser/binding-linux-arm64-musl': 0.127.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.127.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.127.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.127.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.127.0 + '@oxc-parser/binding-linux-x64-gnu': 0.127.0 + '@oxc-parser/binding-linux-x64-musl': 0.127.0 + '@oxc-parser/binding-openharmony-arm64': 0.127.0 + '@oxc-parser/binding-wasm32-wasi': 0.127.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.127.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.127.0 + '@oxc-parser/binding-win32-x64-msvc': 0.127.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + oxlint@1.78.0: optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.78.0 @@ -1383,8 +3026,17 @@ snapshots: dependencies: entities: 8.0.0 + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + pathe@2.0.3: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -1403,6 +3055,25 @@ snapshots: punycode@2.3.1: {} + react-docgen-typescript@2.4.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + react-docgen@8.0.3: + dependencies: + '@babel/core': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + '@types/doctrine': 0.0.9 + '@types/resolve': 1.20.6 + doctrine: 3.0.0 + resolve: 1.22.12 + strip-indent: 4.1.1 + transitivePeerDependencies: + - supports-color + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 @@ -1417,6 +3088,14 @@ snapshots: react@19.2.8: {} + recast@0.23.21: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -1424,6 +3103,13 @@ snapshots: require-from-string@2.0.2: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + rolldown@1.2.4: dependencies: '@oxc-project/types': 0.144.0 @@ -1444,26 +3130,68 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.4 '@rolldown/binding-win32-x64-msvc': 1.2.4 + run-applescript@7.1.0: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 scheduler@0.27.0: {} + semver@6.3.1: {} + + semver@7.8.5: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} + source-map@0.6.1: {} + stackback@0.0.2: {} std-env@4.2.0: {} + storybook@10.5.8(@types/react@19.2.18)(react@19.2.8): + dependencies: + '@storybook/global': 5.0.0 + '@storybook/icons': 2.1.0(react@19.2.8) + '@testing-library/dom': 10.4.1 + '@testing-library/jest-dom': 6.9.1 + '@testing-library/user-event': 14.6.4(@testing-library/dom@10.4.1) + '@vitest/expect': 3.2.4 + '@vitest/spy': 3.2.4 + '@webcontainer/env': 1.1.1 + esbuild: 0.28.2 + jsonc-parser: 3.3.1 + open: 10.2.0 + oxc-parser: 0.127.0 + oxc-resolver: 11.24.2 + recast: 0.23.21 + semver: 7.8.5 + use-sync-external-store: 1.6.0(react@19.2.8) + ws: 8.21.3 + optionalDependencies: + '@types/react': 19.2.18 + transitivePeerDependencies: + - bufferutil + - react + - utf-8-validate + + strip-bom@3.0.0: {} + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 + strip-indent@4.1.1: {} + + supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@1.3.0: {} @@ -1473,8 +3201,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@2.0.0: {} + tinyrainbow@3.1.1: {} + tinyspy@4.0.4: {} + tldts-core@7.4.10: {} tldts@7.4.10: @@ -1489,13 +3221,40 @@ snapshots: dependencies: punycode: 2.3.1 + ts-dedent@2.3.0: {} + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + typescript@6.0.3: {} undici-types@7.18.2: {} undici@8.10.0: {} - vite@8.2.1(@types/node@24.13.3): + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.18.0 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + + vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -1504,12 +3263,13 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 + esbuild: 0.28.2 fsevents: 2.3.3 - vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)): + vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)) + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -1526,7 +3286,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.1(@types/node@24.13.3) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 @@ -1540,6 +3300,8 @@ snapshots: webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@5.0.0: {} whatwg-url@16.0.1: @@ -1563,6 +3325,14 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + ws@8.21.3: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + + yallist@3.1.1: {} diff --git a/frontend/src/App.css b/frontend/src/App.css index b3fab25d..5251e69f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -77,11 +77,11 @@ .popup-close { position: absolute; - top: 0.75rem; - right: 0.75rem; + top: var(--space-close-inset); + right: var(--space-close-inset); background: none; border: none; - font-size: 1.5rem; + font-size: var(--font-size-close); cursor: pointer; } @@ -494,13 +494,13 @@ } .citation-chip { - border: 1px solid #3335; - border-radius: 999px; - padding: 0.1rem 0.6rem; - margin-right: 0.3rem; + border: 1px solid var(--color-chip-border); + border-radius: var(--radius-chip); + padding: var(--space-chip-block) var(--space-chip-inline); + margin-right: var(--space-chip-gap); background: none; cursor: pointer; - font-family: monospace; + font-family: var(--font-family-chip); } .evidence-panel { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 60d06c8d..fd8a1514 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -85,6 +85,7 @@ describe("App, authenticated", () => { let nextTicketId = 1; const events: { event_id: string; event_type: string; actor_account_id: string; summary: string }[] = []; let nextEventId = 1; + let createdPendingLineage: Record | null = null; const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -351,12 +352,14 @@ describe("App, authenticated", () => { }, ], }; + createdPendingLineage = created; return Promise.resolve(new Response(JSON.stringify(created), { status: 201 })); } if (url.endsWith("/api/analysis-runs")) { return Promise.resolve( jsonResponse({ analysis_runs: [ + ...(createdPendingLineage ? [createdPendingLineage] : []), { analysis_run_id: "run-demo-lineage", run_kind_code: "analysis_run_lineage", @@ -1801,10 +1804,15 @@ describe("App, authenticated", () => { await screen.findByRole("heading", { name: "Lineage reconstruction · Pending · Demo Corp" }), ).toBeInTheDocument(); expect( - screen.getByText( + screen.getByRole("button", { + name: "Open analysis run: Lineage reconstruction · Pending · Demo Corp", + }), + ).toBeInTheDocument(); + expect( + screen.getAllByText( "Open this run to confirm which posts it will use. Reconstruction has not started yet.", ), - ).toBeInTheDocument(); + ).toHaveLength(2); const postCall = fetchMock.mock.calls.find( (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d589a364..07088e9d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -58,6 +58,8 @@ import { type RelatedNodeType, type VocEvidence, } from "./api"; +import { CitationChip } from "./components/CitationChip"; +import { PopupCloseButton } from "./components/PopupCloseButton"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { subgraphForPost } from "./lineageLayout"; @@ -112,9 +114,7 @@ function EvidencePanel({ return (

          - +

          Evidence

          {!post &&

          Loading source post...

          } {post && ( @@ -143,14 +143,12 @@ function ChatCitations({
          Sources: {chips.map((cited) => ( - + postId={cited.post_id} + postTitle={cited.post_title} + onOpenEvidence={onOpenEvidence} + /> ))}
          ); @@ -1233,9 +1231,7 @@ function PostDetailPopup({ return (
          event.stopPropagation()}> - + {error &&

          {error}

          } {!post && !error &&

          Loading...

          } {post && ( diff --git a/frontend/src/components/CitationChip.stories.tsx b/frontend/src/components/CitationChip.stories.tsx new file mode 100644 index 00000000..2cce5cc2 --- /dev/null +++ b/frontend/src/components/CitationChip.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CitationChip } from "./CitationChip"; + +const meta = { + title: "Evidence/CitationChip", + component: CitationChip, + args: { + postId: "post-demo-public", + postTitle: "Demo public post", + onOpenEvidence: () => undefined, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const LongTitle: Story = { + args: { + postTitle: "Demo Corp January cutoff reconstruction notes", + }, +}; diff --git a/frontend/src/components/CitationChip.test.tsx b/frontend/src/components/CitationChip.test.tsx new file mode 100644 index 00000000..ff7b948a --- /dev/null +++ b/frontend/src/components/CitationChip.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { CitationChip } from "./CitationChip"; + +describe("CitationChip", () => { + it("opens the cited post when the buyer clicks the chip", async () => { + const onOpenEvidence = vi.fn(); + render( + , + ); + await userEvent.click( + screen.getByRole("button", { name: "Open evidence: Demo public post" }), + ); + expect(onOpenEvidence).toHaveBeenCalledWith("post-demo-public"); + }); +}); diff --git a/frontend/src/components/CitationChip.tsx b/frontend/src/components/CitationChip.tsx new file mode 100644 index 00000000..8ac3f490 --- /dev/null +++ b/frontend/src/components/CitationChip.tsx @@ -0,0 +1,27 @@ +export type CitationChipProps = { + postId: string; + postTitle: string; + onOpenEvidence: (postId: string) => void; +}; + +/** + * Opens the cited source post from a reconstruction caption. + * + * Next action: click the chip to read the evidence that grounded the claim. + */ +export function CitationChip({ + postId, + postTitle, + onOpenEvidence, +}: CitationChipProps) { + return ( + + ); +} diff --git a/frontend/src/components/PopupCloseButton.stories.tsx b/frontend/src/components/PopupCloseButton.stories.tsx new file mode 100644 index 00000000..c697987a --- /dev/null +++ b/frontend/src/components/PopupCloseButton.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PopupCloseButton } from "./PopupCloseButton"; + +const meta = { + title: "Chrome/PopupCloseButton", + component: PopupCloseButton, + args: { + label: "Close evidence panel", + onClose: () => undefined, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const EvidencePanel: Story = {}; + +export const PostPopup: Story = { + args: { + label: "Close", + }, +}; diff --git a/frontend/src/components/PopupCloseButton.test.tsx b/frontend/src/components/PopupCloseButton.test.tsx new file mode 100644 index 00000000..b72e0b91 --- /dev/null +++ b/frontend/src/components/PopupCloseButton.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { PopupCloseButton } from "./PopupCloseButton"; + +describe("PopupCloseButton", () => { + it("closes the evidence panel when the buyer clicks close", async () => { + const onClose = vi.fn(); + render( + , + ); + await userEvent.click( + screen.getByRole("button", { name: "Close evidence panel" }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/PopupCloseButton.tsx b/frontend/src/components/PopupCloseButton.tsx new file mode 100644 index 00000000..129b0729 --- /dev/null +++ b/frontend/src/components/PopupCloseButton.tsx @@ -0,0 +1,22 @@ +export type PopupCloseButtonProps = { + onClose: () => void; + label: string; +}; + +/** + * Closes the evidence panel or post popup. + * + * Next action: click to return to the list or the reconstruction view. + */ +export function PopupCloseButton({ onClose, label }: PopupCloseButtonProps) { + return ( + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 53f4db2a..011ad42f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,12 +1,14 @@ +@import "./styles/tokens.css"; + :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); + --text: var(--color-text); + --text-h: var(--color-text-heading); + --bg: var(--color-background); + --border: var(--color-border); + --code-bg: var(--color-code-background); + --accent: var(--color-accent); + --accent-bg: var(--color-accent-background); + --accent-border: var(--color-accent-border); --social-bg: rgba(244, 243, 236, 0.5); --post-body-gap: 0.75rem; --post-image-padding: 0.75rem; @@ -37,14 +39,14 @@ @media (prefers-color-scheme: dark) { :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); + --text: var(--color-text); + --text-h: var(--color-text-heading); + --bg: var(--color-background); + --border: var(--color-border); + --code-bg: var(--color-code-background); + --accent: var(--color-accent); + --accent-bg: var(--color-accent-background); + --accent-border: var(--color-accent-border); --social-bg: rgba(47, 48, 58, 0.5); --shadow: rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 00000000..e3510b83 --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,32 @@ +:root { + --color-text: #6b6375; + --color-text-heading: #08060d; + --color-background: #fff; + --color-border: #e5e4e7; + --color-code-background: #f4f3ec; + --color-accent: #aa3bff; + --color-accent-background: rgba(170, 59, 255, 0.1); + --color-accent-border: rgba(170, 59, 255, 0.5); + --color-chip-border: #3335; + --space-chip-inline: 0.6rem; + --space-chip-block: 0.1rem; + --space-chip-gap: 0.3rem; + --space-close-inset: 0.75rem; + --radius-chip: 999px; + --font-size-close: 1.5rem; + --font-family-chip: ui-monospace, Consolas, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-text: #9ca3af; + --color-text-heading: #f3f4f6; + --color-background: #16171d; + --color-border: #2e303a; + --color-code-background: #1f2028; + --color-accent: #c084fc; + --color-accent-background: rgba(192, 132, 252, 0.15); + --color-accent-border: rgba(192, 132, 252, 0.5); + --color-chip-border: #9ca3af; + } +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 6830b6f7..d054398d 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -22,5 +22,6 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.stories.tsx"] } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index efe84890..1950c39f 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.2" +__version__ = "0.87.0" diff --git a/migrations/0020_analysis_run_retention_purge.sql b/migrations/0020_analysis_run_retention_purge.sql new file mode 100644 index 00000000..056d8021 --- /dev/null +++ b/migrations/0020_analysis_run_retention_purge.sql @@ -0,0 +1,180 @@ +-- Privileged retention purge for the Milestone 2 analysis-run registry. +-- +-- Migration 0018 makes analysis_run / scope / status immutable, so a +-- documented "export or delete under an approved retention procedure" +-- cannot empty a run-bearing registry. This slice adds that procedure: +-- an audited SECURITY DEFINER purge that disables the immutability +-- triggers only inside the approved call, then records one retention +-- event. +-- +-- Fail-closed authorization is conjunctive (ADR 0020): +-- 1. session_user holds an unrevoked analysis_run_retention_grant; +-- 2. session_user is a member of analysis_run_retention_admin; +-- 3. the documented approval phrase is supplied. +-- PUBLIC cannot execute the function. The phrase is a procedure name, +-- not an authorization secret. A session SET cannot authorize a raw +-- DELETE. Do not grant the admin role to the application DATABASE_URL +-- login, and do not insert a grant for that login. +-- +-- ADR 0019 / migration 0019 belong to the R&R catalog-id bind +-- (cataloged_team_id / cataloged_corporate_entity_id). Do not reuse +-- that number for this purge. + +begin; + +do $$ +begin + if not exists ( + select 1 from pg_roles where rolname = 'analysis_run_retention_admin' + ) then + create role analysis_run_retention_admin nologin nosuperuser inherit; + end if; +end +$$; + +comment on role analysis_run_retention_admin is + 'Least-privilege role that may call purge_analysis_run_registry. ' + 'Grant this role to an operator session, then insert an unrevoked ' + 'analysis_run_retention_grant for session_user. Do not grant it to ' + 'the application DATABASE_URL role.'; + +create table if not exists analysis_run_retention_grant ( + analysis_run_retention_grant_id uuid primary key default gen_random_uuid(), + database_role_name text not null + check (char_length(database_role_name) >= 1), + granted_at timestamptz not null default clock_timestamp(), + revoked_at timestamptz, + check (revoked_at is null or revoked_at >= granted_at) +); + +comment on table analysis_run_retention_grant is + 'Unrevoked row authorizes session_user to call ' + 'purge_analysis_run_registry. Insert one grant for the operator ' + 'role and grant analysis_run_retention_admin before the first purge.'; + +comment on column analysis_run_retention_grant.database_role_name is + 'PostgreSQL session_user that may purge; not an application account.'; + +create unique index if not exists analysis_run_retention_grant_active + on analysis_run_retention_grant (database_role_name) + where revoked_at is null; + +create table if not exists analysis_run_retention_event ( + analysis_run_retention_event_id uuid primary key default gen_random_uuid(), + approved_at timestamptz not null default clock_timestamp(), + purged_run_count bigint not null check (purged_run_count >= 0), + purged_snapshot_count bigint not null check (purged_snapshot_count >= 0), + approval_token_digest text not null + check (approval_token_digest ~ '^[0-9a-f]{64}$'), + invoking_session_role name not null, + invoking_current_role name not null, + client_network_address inet +); + +comment on table analysis_run_retention_event is + 'One audit row per approved registry purge; export then delete before ' + 'rolling back migration 0020.'; + +comment on column analysis_run_retention_event.approval_token_digest is + 'SHA-256 hex of the approval token; the raw phrase is never stored.'; + +comment on column analysis_run_retention_event.invoking_session_role is + 'session_user at purge time: the login role that held the grant.'; + +comment on column analysis_run_retention_event.invoking_current_role is + 'current_user at purge time: the SECURITY DEFINER owner while the ' + 'function runs.'; + +comment on column analysis_run_retention_event.client_network_address is + 'inet_client_addr() when the caller is remote; NULL for local sockets.'; + +create or replace function purge_analysis_run_registry(approval_token text) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + run_count bigint; + snapshot_count bigint; +begin + if not exists ( + select 1 + from analysis_run_retention_grant + where database_role_name = session_user + and revoked_at is null + ) then + raise exception 'analysis_run_retention_not_granted'; + end if; + + if not pg_has_role(session_user, 'analysis_run_retention_admin', 'member') then + raise exception 'analysis_run_retention_not_admin'; + end if; + + if approval_token is distinct from 'approved-retention-purge' then + raise exception 'analysis_run_retention_not_approved'; + end if; + + select count(*) into run_count from analysis_run; + select count(*) into snapshot_count from analysis_source_snapshot; + + alter table analysis_run_status_event + disable trigger analysis_run_status_event_delete_reject; + alter table analysis_run_scope + disable trigger analysis_run_scope_mutation_reject; + alter table analysis_run + disable trigger analysis_run_mutation_reject; + + begin + delete from analysis_run_status_event; + delete from analysis_run_scope; + delete from analysis_run; + delete from analysis_source_count; + delete from analysis_source_snapshot; + exception + when others then + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + raise; + end; + + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + + insert into analysis_run_retention_event ( + purged_run_count, + purged_snapshot_count, + approval_token_digest, + invoking_session_role, + invoking_current_role, + client_network_address + ) values ( + run_count, + snapshot_count, + encode(sha256(convert_to(approval_token, 'UTF8')), 'hex'), + session_user, + current_user, + inet_client_addr() + ); +end +$$; + +comment on function purge_analysis_run_registry(text) is + 'Empties immutable registry relations after an unrevoked role grant, ' + 'analysis_run_retention_admin membership, and the documented approval ' + 'token; records one analysis_run_retention_event. Next action: export ' + 'that event, delete it, then roll back 0020 and 0018.'; + +revoke all on function purge_analysis_run_registry(text) from public; +grant execute on function purge_analysis_run_registry(text) + to analysis_run_retention_admin; + +commit; diff --git a/migrations/rollback/0018_analysis_run_registry.sql b/migrations/rollback/0018_analysis_run_registry.sql index f91abc47..ff35011b 100644 --- a/migrations/rollback/0018_analysis_run_registry.sql +++ b/migrations/rollback/0018_analysis_run_registry.sql @@ -1,7 +1,9 @@ -- Fail-closed rollback for migration 0018. -- --- Registry evidence must be exported or explicitly deleted under an approved --- retention procedure before these objects can be removed. Re-running this +-- Registry evidence must be exported, then emptied with +-- select purge_analysis_run_registry('approved-retention-purge') +-- (migration 0020 / ADR 0020), before these objects can be removed. A raw +-- DELETE of analysis_run / scope / status is rejected. Re-running this -- rollback after a successful empty rollback is safe. begin; diff --git a/migrations/rollback/0020_analysis_run_retention_purge.sql b/migrations/rollback/0020_analysis_run_retention_purge.sql new file mode 100644 index 00000000..fd89611f --- /dev/null +++ b/migrations/rollback/0020_analysis_run_retention_purge.sql @@ -0,0 +1,33 @@ +-- Fail-closed rollback for migration 0020. +-- +-- Export analysis_run_retention_event, then delete those rows, before +-- this script can drop the purge function, grant table, and audit +-- table. Grant rows are authorization config and drop with the table. +-- Re-running after a successful empty rollback is safe. + +begin; + +do $$ +declare + relation_has_rows boolean; +begin + if to_regclass('public.analysis_run_retention_event') is not null then + execute 'select exists (select 1 from analysis_run_retention_event)' + into relation_has_rows; + if relation_has_rows then + raise exception 'analysis_run_retention_event_not_empty'; + end if; + end if; +end +$$; + +drop function if exists purge_analysis_run_registry(text); +drop table if exists analysis_run_retention_grant; +drop table if exists analysis_run_retention_event; + +-- analysis_run_retention_admin is cluster-scoped. Leave it in place so a +-- parallel database that still has 0020 applied does not lose the role. +-- Revoke leftover memberships before dropping the role in a dedicated +-- cluster teardown. + +commit; diff --git a/pyproject.toml b/pyproject.toml index 6e41c7ca..ecfe2487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.2" +version = "0.87.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 9d246445..2f3c66c4 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -121,6 +121,7 @@ def seed( cur.execute((migrations / "0016_cross_post_actor_identity.sql").read_text()) cur.execute((migrations / "0018_analysis_run_registry.sql").read_text()) cur.execute((migrations / "0019_role_catalog_identity.sql").read_text()) + cur.execute((migrations / "0020_analysis_run_retention_purge.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values diff --git a/tests/test_analysis_run_authorization.py b/tests/test_analysis_run_authorization.py index 730825c1..64a0504f 100644 --- a/tests/test_analysis_run_authorization.py +++ b/tests/test_analysis_run_authorization.py @@ -14,6 +14,7 @@ _ROOT = Path(__file__).resolve().parents[1] _INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" _REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" +_RETENTION_MIGRATION = _ROOT / "migrations" / "0020_analysis_run_retention_purge.sql" _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" ) @@ -53,6 +54,7 @@ def authz_db(): with connection.cursor() as cursor: cursor.execute(_INITIAL_MIGRATION.read_text(encoding="utf-8")) cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_RETENTION_MIGRATION.read_text(encoding="utf-8")) yield connection finally: connection.close() diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index f2b38bad..3d185dbe 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import os import re import uuid @@ -17,6 +18,10 @@ _INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" _REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" _REGISTRY_ROLLBACK = _ROOT / "migrations" / "rollback" / "0018_analysis_run_registry.sql" +_RETENTION_MIGRATION = _ROOT / "migrations" / "0020_analysis_run_retention_purge.sql" +_RETENTION_ROLLBACK = ( + _ROOT / "migrations" / "rollback" / "0020_analysis_run_retention_purge.sql" +) _POSTGRES_IMAGE = _ROOT / "docker" / "postgres-init" / "Dockerfile" _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" @@ -98,6 +103,7 @@ def registry_db(): with connection.cursor() as cursor: cursor.execute(_INITIAL_MIGRATION.read_text(encoding="utf-8")) cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_RETENTION_MIGRATION.read_text(encoding="utf-8")) yield connection finally: connection.close() @@ -183,6 +189,74 @@ def _insert_run( return str(cursor.fetchone()[0]) +def _insert_run_bearing_registry( + cursor, + *, + digest: str, + idempotency_key: str, +) -> None: + """Insert one snapshot, count, run, scope, and pending event.""" + + account_id = _insert_account(cursor) + snapshot_id = _insert_snapshot(cursor, digest=digest) + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_document', 3)", + (snapshot_id,), + ) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key=idempotency_key, + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code) " + "values (%s, 'analysis_scope_all_visible')", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z')", + (run_id,), + ) + + +def _authorize_session_for_purge(cursor) -> str: + """Grant the current session_user both retention locks and return it.""" + + cursor.execute("select session_user") + session_role = cursor.fetchone()[0] + cursor.execute( + "insert into analysis_run_retention_grant (database_role_name) " + "select %s " + "where not exists (" + " select 1 from analysis_run_retention_grant " + " where database_role_name = %s and revoked_at is null" + ")", + (session_role, session_role), + ) + cursor.execute( + sql.SQL("grant analysis_run_retention_admin to {}").format( + sql.Identifier(session_role) + ) + ) + return session_role + + +def _drop_role_if_exists(cursor, role_name: str) -> None: + """Drop a test role after releasing objects it owns.""" + + cursor.execute("select 1 from pg_roles where rolname = %s", (role_name,)) + if cursor.fetchone() is None: + return + cursor.execute(sql.SQL("drop owned by {}").format(sql.Identifier(role_name))) + cursor.execute(sql.SQL("drop role {}").format(sql.Identifier(role_name))) + + def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> None: """Static contract rejects the parallel prototype and duplicated clocks.""" @@ -200,7 +274,38 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non re.findall(r"'(analysis_[a-z0-9_]+)'", migration) ) assert "0018_analysis_run_registry.sql" in dockerfile + assert "0019_role_catalog_identity.sql" in dockerfile + assert "0020_analysis_run_retention_purge.sql" in dockerfile + seed = (_ROOT / "scripts" / "seed_demo_data.py").read_text(encoding="utf-8") + assert seed.index("0019_role_catalog_identity.sql") < seed.index( + "0020_analysis_run_retention_purge.sql" + ) assert "analysis_run_registry_not_empty" in rollback + retention = _RETENTION_MIGRATION.read_text(encoding="utf-8") + retention_rollback = _RETENTION_ROLLBACK.read_text(encoding="utf-8") + assert "purge_analysis_run_registry" in retention + assert "analysis_run_retention_event" in retention + assert "analysis_run_retention_grant" in retention + assert "analysis_run_retention_admin" in retention + assert "invoking_session_role" in retention + assert "invoking_current_role" in retention + assert "security definer" in retention.casefold() + assert "revoke all" in retention.casefold() + assert "from public" in retention.casefold() + assert "analysis_run_retention_not_approved" in retention + assert "analysis_run_retention_not_granted" in retention + assert "analysis_run_retention_not_admin" in retention + assert "analysis_run_retention_event_not_empty" in retention_rollback + assert "jsonb" not in retention.casefold() + for object_name in re.findall( + r"create table if not exists\s+([a-z0-9_]+)" + r"|create or replace function\s+([a-z0-9_]+)" + r"|create role\s+([a-z0-9_]+)", + retention, + re.I, + ): + name = object_name[0] or object_name[1] or object_name[2] + assert len(name.split("_")) >= 2, name snapshot_definition = _table_definition(migration, "analysis_source_snapshot") run_definition = _table_definition(migration, "analysis_run") @@ -235,6 +340,7 @@ def test_registry_migration_is_idempotent(registry_db) -> None: with registry_db.cursor() as cursor: cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_RETENTION_MIGRATION.read_text(encoding="utf-8")) cursor.execute( "select table_name from information_schema.tables " "where table_schema = 'public'" @@ -246,6 +352,8 @@ def test_registry_migration_is_idempotent(registry_db) -> None: ) views = {row[0] for row in cursor.fetchall()} assert _REQUIRED_TABLES <= tables + assert "analysis_run_retention_event" in tables + assert "analysis_run_retention_grant" in tables assert "analysis_run_current_status" in views @@ -714,3 +822,319 @@ def test_rollback_refuses_data_loss_then_removes_an_empty_registry(registry_db) cursor.execute("select to_regclass('public.analysis_run')") assert cursor.fetchone()[0] is None cursor.execute(rollback_sql) + + +def test_approved_retention_purge_empties_a_run_bearing_registry(registry_db) -> None: + """Grant plus admin empties expired registry rows; a raw DELETE still fails.""" + + rollback_sql = _REGISTRY_ROLLBACK.read_text(encoding="utf-8") + retention_rollback = _RETENTION_ROLLBACK.read_text(encoding="utf-8") + with registry_db.cursor() as cursor: + _insert_run_bearing_registry( + cursor, + digest="a" * 64, + idempotency_key="retention-purge", + ) + cursor.execute("select analysis_run_id from analysis_run") + run_id = cursor.fetchone()[0] + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_request_is_immutable", + ): + cursor.execute( + "delete from analysis_run where analysis_run_id = %s", + (run_id,), + ) + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_registry_not_empty", + ): + cursor.execute(rollback_sql) + cursor.execute("rollback") + session_role = _authorize_session_for_purge(cursor) + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_retention_not_approved", + ): + cursor.execute("select purge_analysis_run_registry(%s)", ("wrong-token",)) + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("select count(*) from analysis_run") + assert cursor.fetchone()[0] == 0 + cursor.execute("select count(*) from analysis_source_snapshot") + assert cursor.fetchone()[0] == 0 + cursor.execute( + "select purged_run_count, purged_snapshot_count, " + "approval_token_digest, invoking_session_role, " + "invoking_current_role from analysis_run_retention_event" + ) + ( + purged_run_count, + purged_snapshot_count, + token_digest, + invoking_session_role, + invoking_current_role, + ) = cursor.fetchone() + assert purged_run_count == 1 + assert purged_snapshot_count == 1 + assert token_digest == hashlib.sha256( + b"approved-retention-purge" + ).hexdigest() + assert invoking_session_role == session_role + assert invoking_current_role + cursor.execute(rollback_sql) + cursor.execute("select to_regclass('public.analysis_run')") + assert cursor.fetchone()[0] is None + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_retention_event_not_empty", + ): + cursor.execute(retention_rollback) + cursor.execute("rollback") + cursor.execute("delete from analysis_run_retention_event") + cursor.execute(retention_rollback) + cursor.execute( + "select to_regclass('public.analysis_run_retention_event')" + ) + assert cursor.fetchone()[0] is None + + +def test_retention_purge_requires_unrevoked_session_grant(registry_db) -> None: + """Admin membership plus the published token cannot purge without a grant.""" + + role_name = f"retention_denied_{uuid.uuid4().hex[:8]}" + with registry_db.cursor() as cursor: + cursor.execute( + "select has_function_privilege(%s, %s, 'execute')", + ("public", "purge_analysis_run_registry(text)"), + ) + assert cursor.fetchone()[0] is False + _insert_run_bearing_registry( + cursor, + digest="d" * 64, + idempotency_key="retention-grant-deny", + ) + cursor.execute( + sql.SQL("create role {} nologin nosuperuser inherit").format( + sql.Identifier(role_name) + ) + ) + cursor.execute( + sql.SQL("grant analysis_run_retention_admin to {}").format( + sql.Identifier(role_name) + ) + ) + try: + cursor.execute( + sql.SQL("set session authorization {}").format( + sql.Identifier(role_name) + ) + ) + except psycopg2.errors.InsufficientPrivilege: + cursor.execute("reset session authorization") + _drop_role_if_exists(cursor, role_name) + pytest.skip("session authorization requires superuser") + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_retention_not_granted", + ): + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("reset session authorization") + cursor.execute( + "insert into analysis_run_retention_grant (database_role_name) " + "values (%s)", + (role_name,), + ) + cursor.execute( + sql.SQL("set session authorization {}").format( + sql.Identifier(role_name) + ) + ) + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("reset session authorization") + cursor.execute( + "select invoking_session_role from analysis_run_retention_event" + ) + assert cursor.fetchone()[0] == role_name + cursor.execute( + "update analysis_run_retention_grant " + "set revoked_at = clock_timestamp() " + "where database_role_name = %s and revoked_at is null", + (role_name,), + ) + _insert_run_bearing_registry( + cursor, + digest="e" * 64, + idempotency_key="retention-grant-revoked", + ) + cursor.execute( + sql.SQL("set session authorization {}").format( + sql.Identifier(role_name) + ) + ) + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_retention_not_granted", + ): + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("reset session authorization") + _drop_role_if_exists(cursor, role_name) + + +def test_runtime_role_cannot_purge_with_only_the_public_token(registry_db) -> None: + """Table DML plus the documented phrase is not a retention grant.""" + + runtime_role = f"analysis_run_app_{uuid.uuid4().hex[:12]}" + operator_role = f"analysis_run_operator_{uuid.uuid4().hex[:12]}" + try: + with registry_db.cursor() as cursor: + _insert_run_bearing_registry( + cursor, + digest="f" * 64, + idempotency_key="runtime-denied-purge", + ) + cursor.execute("select analysis_run_id from analysis_run") + run_id = cursor.fetchone()[0] + cursor.execute( + sql.SQL( + "create role {} nologin nosuperuser inherit" + ).format(sql.Identifier(runtime_role)) + ) + cursor.execute( + sql.SQL( + "create role {} nologin nosuperuser inherit" + ).format(sql.Identifier(operator_role)) + ) + cursor.execute( + sql.SQL("grant usage on schema public to {}, {}").format( + sql.Identifier(runtime_role), + sql.Identifier(operator_role), + ) + ) + cursor.execute( + sql.SQL( + "grant select, insert, update, delete on " + "analysis_run, analysis_run_scope, " + "analysis_run_status_event, analysis_source_snapshot, " + "analysis_source_count to {}" + ).format(sql.Identifier(runtime_role)) + ) + cursor.execute( + sql.SQL("grant analysis_run_retention_admin to {}").format( + sql.Identifier(operator_role) + ) + ) + cursor.execute( + "insert into analysis_run_retention_grant (database_role_name) " + "values (%s)", + (operator_role,), + ) + cursor.execute( + sql.SQL("set role {}").format(sql.Identifier(runtime_role)) + ) + with pytest.raises(psycopg2.errors.InsufficientPrivilege): + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_request_is_immutable", + ): + cursor.execute( + "delete from analysis_run where analysis_run_id = %s", + (run_id,), + ) + cursor.execute("reset role") + try: + cursor.execute( + sql.SQL("set session authorization {}").format( + sql.Identifier(operator_role) + ) + ) + except psycopg2.errors.InsufficientPrivilege: + cursor.execute("reset session authorization") + pytest.skip("session authorization requires superuser") + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("reset session authorization") + cursor.execute("select count(*) from analysis_run") + assert cursor.fetchone()[0] == 0 + cursor.execute( + "select invoking_session_role, invoking_current_role " + "from analysis_run_retention_event" + ) + invoking_session_role, invoking_current_role = cursor.fetchone() + assert invoking_session_role + assert invoking_current_role + finally: + with registry_db.cursor() as cursor: + cursor.execute("reset role") + cursor.execute("reset session authorization") + for role_name in (runtime_role, operator_role): + _drop_role_if_exists(cursor, role_name) + + +def test_retention_purge_requires_admin_membership_even_with_a_grant( + registry_db, +) -> None: + """A grant without analysis_run_retention_admin cannot empty the registry.""" + + role_name = f"retention_grant_only_{uuid.uuid4().hex[:8]}" + with registry_db.cursor() as cursor: + _insert_run_bearing_registry( + cursor, + digest="c" * 64, + idempotency_key="retention-admin-deny", + ) + cursor.execute( + sql.SQL("create role {} nologin nosuperuser inherit").format( + sql.Identifier(role_name) + ) + ) + cursor.execute( + sql.SQL( + "grant execute on function purge_analysis_run_registry(text) " + "to {}" + ).format(sql.Identifier(role_name)) + ) + cursor.execute( + "insert into analysis_run_retention_grant (database_role_name) " + "values (%s)", + (role_name,), + ) + try: + cursor.execute( + sql.SQL("set session authorization {}").format( + sql.Identifier(role_name) + ) + ) + except psycopg2.errors.InsufficientPrivilege: + cursor.execute("reset session authorization") + _drop_role_if_exists(cursor, role_name) + pytest.skip("session authorization requires superuser") + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_retention_not_admin", + ): + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("reset session authorization") + cursor.execute("select count(*) from analysis_run") + assert cursor.fetchone()[0] == 1 + _drop_role_if_exists(cursor, role_name) diff --git a/uv.lock b/uv.lock index e1d2860c..6915a353 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.2" +version = "0.87.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 1cf0bd7a530b12b7555eef42bdf6d8da47e00106 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:52:18 +0900 Subject: [PATCH 117/161] feat: start a pending lineage reconstruction (v0.88.0) POST /api/analysis-runs/{id}/start runs ThreadWeave on a frozen Pending lineage cutoff bag and persists run-scoped edges (ADR 0021). Succeeded retries replay the stored digest; Running is 409. TEPP and period-report start stay 422 so this path never invents a theta. --- AGENTS.md | 4 + ARCHITECTURE.md | 15 +- CHANGELOG.d/0.88.0-analysis-run-start.md | 5 + CHANGELOG.md | 14 + CLAUDE.md | 4 +- backend/app/analysis_run_ingestion.py | 124 ++++++- backend/app/analysis_run_start.py | 336 ++++++++++++++++++ backend/app/main.py | 31 ++ backend/tests/test_api.py | 218 ++++++++++++ docker/postgres-init/Dockerfile | 2 + .../0013-normalized-analysis-run-registry.md | 7 +- .../0017-authorized-analysis-run-create.md | 10 +- .../adr/0021-authorized-analysis-run-start.md | 112 ++++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 10 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 89 ++++- frontend/src/App.tsx | 79 +++- frontend/src/api.ts | 19 + lineageweave/__init__.py | 2 +- .../0021_analysis_run_reconstruction.sql | 79 ++++ .../0022_analysis_source_snapshot_member.sql | 35 ++ .../0021_analysis_run_reconstruction.sql | 37 ++ .../0022_analysis_source_snapshot_member.sql | 27 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 33 ++ ...test_analysis_run_reconstruction_schema.py | 150 ++++++++ tests/test_analysis_run_registry_schema.py | 8 + tests/test_analysis_run_start.py | 119 +++++++ 28 files changed, 1541 insertions(+), 32 deletions(-) create mode 100644 CHANGELOG.d/0.88.0-analysis-run-start.md create mode 100644 backend/app/analysis_run_start.py create mode 100644 docs/adr/0021-authorized-analysis-run-start.md create mode 100644 migrations/0021_analysis_run_reconstruction.sql create mode 100644 migrations/0022_analysis_source_snapshot_member.sql create mode 100644 migrations/rollback/0021_analysis_run_reconstruction.sql create mode 100644 migrations/rollback/0022_analysis_source_snapshot_member.sql create mode 100644 tests/test_analysis_run_reconstruction_schema.py create mode 100644 tests/test_analysis_run_start.py diff --git a/AGENTS.md b/AGENTS.md index 47a71c8c..cebc790a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,10 @@ 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/{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. + ## CI gates `.github/workflows/tests.yml` runs the full suite on every PR to `main`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf19c5f7..96b775ba 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -472,10 +472,14 @@ run's scope whose `created_at` is at or before `knowledge_cutoff` 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. +cutoff capture (ADR 0017): snapshot, counts, frozen membership, run, +scope, and the first status in one transaction. +`POST /api/analysis-runs/{id}/start` then runs ThreadWeave on that +frozen bag and persists run-scoped edges (ADR 0021). It does not invent +a TEPP score. Request a lineage reconstruction from the home list, open +the Pending row, then start reconstruction. Hover the Result digest +prefix, then confirm the designed A-100 fork before treating the live +Event Lineage panel as that run's tree. `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. @@ -492,7 +496,8 @@ calibrated negative result. A failed lineage row tells the operator to retry reconstruction, not to connect TEPP. A failed period-report row tells the operator to rebuild the report. A pending TEPP row does not claim a calibrated measurement. A pending lineage row -says reconstruction has not started yet. The +says reconstruction has not started yet; open it and start +reconstruction. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · diff --git a/CHANGELOG.d/0.88.0-analysis-run-start.md b/CHANGELOG.d/0.88.0-analysis-run-start.md new file mode 100644 index 00000000..a94209c2 --- /dev/null +++ b/CHANGELOG.d/0.88.0-analysis-run-start.md @@ -0,0 +1,5 @@ +# 0.88.0 start a pending lineage reconstruction + +`POST /api/analysis-runs/{id}/start` runs ThreadWeave on a Pending +lineage cutoff bag and persists run-scoped edges. Start reconstruction +from the open run. This path does not invent a TEPP measurement. diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a19fe9..42239b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.88.0] - 2026-08-16 + +### Added + +- `POST /api/analysis-runs/{id}/start` runs ThreadWeave on a visible + Pending lineage cutoff bag and persists run-scoped parent choices + (ADR 0021). Open the Pending Demo Corp row, then start reconstruction. + The designed A-100 fork (revised quote and delivery question under the + pricing follow-up) is the acceptance tree. TEPP and period-report + start are 422 — this path does not invent a theta. A Succeeded retry + returns the stored digest. A Running restart is 409. Create freezes + authorized post ids so start cannot pick up a later backfill. Live + Event Lineage stays a separate rebuild. + ## [0.87.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 870c77f8..5096a8ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,4 +32,6 @@ 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. +cutoff capture (ADR 0017). `POST /api/analysis-runs/{id}/start` +reconstructs that frozen cutoff bag (ADR 0021) and does not invent a +theta. Hover the Result prefix to read the parent-choice digest. diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index d26eb6f6..4fe53d76 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -6,9 +6,10 @@ lookup labels come back; source SQL, DSNs, raw records, and provider 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. +``create_pending_analysis_run`` (ADR 0017) writes snapshot, counts, frozen +membership, run, scope, and the first Pending event atomically. +``start_pending_analysis_run`` (ADR 0021) later reconstructs lineage on +that cutoff bag. Neither path invents a TEPP score. """ from __future__ import annotations @@ -247,9 +248,123 @@ async def fetch_visible_analysis_run( affiliated_entity_ids, row["knowledge_cutoff"], ) + digest, edges = await fetch_reconstructed_edges( + conn, + analysis_run_id, + affiliated_entity_ids, + ) + if digest is not None: + detail["reconstruction_result_sha256"] = digest + detail["reconstructed_edges"] = edges return detail +def reconstructed_edge_is_visible( + *, + parent_visibility_code: str, + parent_corporate_entity_id: Any, + child_visibility_code: str, + child_corporate_entity_id: Any, + affiliated_entity_ids: list[str], +) -> bool: + """Hide an edge when either endpoint is outside the caller's ABAC bag.""" + affiliated = {str(entity_id) for entity_id in affiliated_entity_ids} + parent_visible = ( + parent_visibility_code == "public" + or str(parent_corporate_entity_id) in affiliated + ) + child_visible = ( + child_visibility_code == "public" + or str(child_corporate_entity_id) in affiliated + ) + return parent_visible and child_visible + + +async def fetch_reconstructed_edges( + conn: asyncpg.Connection, + analysis_run_id: str, + affiliated_entity_ids: list[str], +) -> tuple[str | None, list[dict[str, Any]]]: + """Return the persisted digest and titled edges, or ``(None, [])``. + + Missing reconstruction tables mean this database has not applied + migration 0021 yet; treat that as no stored tree rather than 500. + Titles follow the same public-or-affiliated rule as ``visible_posts``. + """ + try: + header = await conn.fetchrow( + """ + select result_sha256 + from analysis_run_reconstruction + where analysis_run_id = $1 + """, + analysis_run_id, + ) + except asyncpg.UndefinedTableError: + return None, [] + if header is None: + return None, [] + rows = await conn.fetch( + """ + select + edge.parent_post_id, + parent_post.post_title as parent_post_title, + parent_post.visibility_code as parent_visibility_code, + parent_post.corporate_entity_id as parent_corporate_entity_id, + edge.child_post_id, + child_post.post_title as child_post_title, + child_post.visibility_code as child_visibility_code, + child_post.corporate_entity_id as child_corporate_entity_id, + edge.fused_score + from analysis_run_lineage_edge edge + join source_post parent_post on parent_post.post_id = edge.parent_post_id + join source_post child_post on child_post.post_id = edge.child_post_id + where edge.analysis_run_id = $1 + order by parent_post.post_title, child_post.post_title + """, + analysis_run_id, + ) + return header["result_sha256"], [ + { + "parent_post_id": str(row["parent_post_id"]), + "parent_post_title": row["parent_post_title"], + "child_post_id": str(row["child_post_id"]), + "child_post_title": row["child_post_title"], + "fused_score": float(row["fused_score"]), + } + for row in rows + if reconstructed_edge_is_visible( + parent_visibility_code=row["parent_visibility_code"], + parent_corporate_entity_id=row["parent_corporate_entity_id"], + child_visibility_code=row["child_visibility_code"], + child_corporate_entity_id=row["child_corporate_entity_id"], + affiliated_entity_ids=affiliated_entity_ids, + ) + ] + + +async def persist_snapshot_members( + conn: asyncpg.Connection, + snapshot_id: Any, + post_ids: list[str], +) -> None: + """Freeze authorized post ids on a new snapshot. Skip a legacy database.""" + if not post_ids: + return + try: + await conn.executemany( + """ + insert into analysis_source_snapshot_member + (analysis_source_snapshot_id, source_post_id) + values ($1, $2) + on conflict do nothing + """, + [(snapshot_id, post_id) for post_id in post_ids], + ) + except asyncpg.UndefinedTableError: + return + + async def fetch_visible_scope_posts( conn: asyncpg.Connection, scope_kind_code: str, @@ -441,7 +556,7 @@ async def create_pending_analysis_run( knowledge_cutoff: datetime | None, idempotency_key: str, ) -> dict[str, Any]: - """Insert snapshot, counts, run, scope, and Pending in one transaction. + """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 @@ -574,6 +689,7 @@ async def create_pending_analysis_run( capture.document_count, capture.thread_count, ) + await persist_snapshot_members(conn, snapshot_id, post_ids) try: run_id = await conn.fetchval( """ diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py new file mode 100644 index 00000000..0e9e3a2e --- /dev/null +++ b/backend/app/analysis_run_start.py @@ -0,0 +1,336 @@ +"""Start a Pending lineage reconstruction without inventing a TEPP score. + +ADR 0021. ``POST /api/analysis-runs/{id}/start`` transitions Pending to +Running, runs ThreadWeave on the frozen cutoff bag, persists run-scoped +edges, then stamps Succeeded. TEPP and period-report stay other paths. +""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +import asyncpg + +from backend.app.analysis_run_ingestion import ( + AnalysisRunCreateError, + fetch_visible_analysis_run, +) +from backend.app.lineage_ingestion import records_from_source_posts +from lineageweave.lineage_persistence import lineage_edge_specs +from lineageweave.models import Edge + +_LINEAGE_KIND = "analysis_run_lineage" +_TEPP_KIND = "analysis_run_tepp" +_REPORT_KIND = "analysis_run_report" +_PENDING = "analysis_status_pending" +_RUNNING = "analysis_status_running" +_SUCCEEDED = "analysis_status_succeeded" + + +class AnalysisRunStartError(AnalysisRunCreateError): + """Fail-closed start: HTTP status plus a next-action detail string.""" + + +def reconstruction_result_digest(edges: list[Edge]) -> str: + """SHA-256 of the ordered parent choices. Never hashes a post body.""" + material = json.dumps( + [ + { + "child_post_id": edge.child_id, + "fused_score": round(float(edge.fused_score), 6), + "parent_post_id": edge.parent_id, + } + for edge in sorted(edges, key=lambda item: (item.child_id, item.parent_id)) + ], + separators=(",", ":"), + sort_keys=True, + ) + return hashlib.sha256(material.encode()).hexdigest() + + +def start_kind_rejection(run_kind_code: str) -> AnalysisRunStartError | None: + """Return a 422 when start is not a lineage reconstruction. + + TEPP and period-report keep their own transports. This path must not + invent a theta or a calibrated report score. + """ + if run_kind_code == _LINEAGE_KIND: + return None + if run_kind_code == _TEPP_KIND: + return AnalysisRunStartError( + 422, + "Connect a TEPP transport from a Failed TEPP row. " + "This start path does not invent a measurement.", + ) + if run_kind_code == _REPORT_KIND: + return AnalysisRunStartError( + 422, + "Rebuild the period report from the reports panel. " + "This start path does not invent a measurement.", + ) + return AnalysisRunStartError( + 422, + "Start reconstructs a Pending lineage run only. " + "This start path does not invent a measurement.", + ) + + +def start_write_conflict_error() -> AnalysisRunStartError: + """Next action when a concurrent start already wrote this run.""" + return AnalysisRunStartError( + 409, + "Open this run. Refresh to see the stored tree if start already finished.", + ) + + +def reconstruction_member_ids( + snapshot_member_ids: list[str], + cutoff_post_ids: list[str], +) -> list[str]: + """Prefer create-time membership over a later cutoff re-query. + + An empty member list means this database has not frozen the bag yet + (migration 0022 missing, or a legacy snapshot). Start then uses the + live cutoff query so those rows still reconstruct. + """ + if snapshot_member_ids: + return list(snapshot_member_ids) + return list(cutoff_post_ids) + + +async def _cutoff_source_posts( + conn: asyncpg.Connection, + *, + corporate_entity_id: Any, + knowledge_cutoff: Any, + affiliated_entity_ids: list[str], +) -> list[asyncpg.Record]: + """ABAC-visible cutoff rows with the grouping keys reconstruct needs.""" + rows = await conn.fetch( + """ + select post_id, post_title, created_at, visibility_code, + corporate_entity_id, process_unit_id, + thread_group_key, secondary_grouping_key + from source_post + where corporate_entity_id = $1 and created_at <= $2 + order by created_at, post_title + """, + corporate_entity_id, + knowledge_cutoff, + ) + affiliated = {str(entity_id) for entity_id in affiliated_entity_ids} + return [ + row + for row in rows + if row["visibility_code"] == "public" + or str(row["corporate_entity_id"]) in affiliated + ] + + +async def _snapshot_member_posts( + conn: asyncpg.Connection, + snapshot_id: Any, +) -> list[asyncpg.Record]: + """Load frozen capture rows, or empty when the member table is absent.""" + try: + return list( + await conn.fetch( + """ + select post.post_id, post.post_title, post.created_at, + post.visibility_code, post.corporate_entity_id, + post.process_unit_id, post.thread_group_key, + post.secondary_grouping_key + from analysis_source_snapshot_member member + join source_post post on post.post_id = member.source_post_id + where member.analysis_source_snapshot_id = $1 + order by post.created_at, post.post_title + """, + snapshot_id, + ) + ) + except asyncpg.UndefinedTableError: + return [] + + +async def _append_status( + conn: asyncpg.Connection, + analysis_run_id: str, + status_ordinal: int, + status_code: str, + occurred_at: datetime, + failure_code: str | None = None, +) -> None: + """Append one legal lifecycle event. Failed rows carry a machine code.""" + await conn.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at, failure_code) + values ($1, $2, $3, $4, $5) + """, + analysis_run_id, + status_ordinal, + status_code, + occurred_at, + failure_code, + ) + + +async def _next_status_ordinal( + conn: asyncpg.Connection, + analysis_run_id: str, +) -> int: + """Return the next contiguous status ordinal for this run.""" + current_max = await conn.fetchval( + """ + select coalesce(max(status_ordinal), 0) + from analysis_run_status_event + where analysis_run_id = $1 + """, + analysis_run_id, + ) + return int(current_max) + 1 + + +async def start_pending_analysis_run( + conn: asyncpg.Connection, + *, + analysis_run_id: str, + account_id: str, + affiliated_entity_ids: list[str], +) -> dict[str, Any]: + """Run ThreadWeave on a visible Pending lineage row. + + TEPP and period-report are rejected so this path cannot invent a + theta. A Succeeded retry returns the stored reconstruction (documented + no-op replay). A Running or concurrent write is 409. Hidden runs 404. + The run row is locked before Running so a double-click is 409 or a + replay, never a 500. + """ + try: + UUID(analysis_run_id) + except ValueError as exc: + raise AnalysisRunStartError(404, "This analysis run is not visible.") from exc + + current = await fetch_visible_analysis_run( + conn, + analysis_run_id, + account_id, + affiliated_entity_ids, + ) + if current is None: + raise AnalysisRunStartError(404, "This analysis run is not visible.") + kind_error = start_kind_rejection(current["run_kind_code"]) + if kind_error is not None: + raise kind_error + if current["status_code"] == _SUCCEEDED: + return current + if current["status_code"] != _PENDING: + raise AnalysisRunStartError( + 409, + "Open this run. Start is only for a Pending lineage reconstruction.", + ) + + locked = await conn.fetchrow( + """ + select run.analysis_run_id, run.knowledge_cutoff, + run.analysis_source_snapshot_id, scope.corporate_entity_id + from analysis_run run + join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id + where run.analysis_run_id = $1 + for update of run + """, + analysis_run_id, + ) + locked_status = await conn.fetchval( + """ + select status_code + from analysis_run_current_status + where analysis_run_id = $1 + """, + analysis_run_id, + ) + if locked_status == _SUCCEEDED: + replayed = await fetch_visible_analysis_run( + conn, + analysis_run_id, + account_id, + affiliated_entity_ids, + ) + if replayed is None: + raise AnalysisRunStartError(404, "This analysis run is not visible.") + return replayed + if locked_status != _PENDING: + raise AnalysisRunStartError( + 409, + "Open this run. Start is only for a Pending lineage reconstruction.", + ) + + now = datetime.now(timezone.utc) + running_ordinal = await _next_status_ordinal(conn, analysis_run_id) + try: + await _append_status(conn, analysis_run_id, running_ordinal, _RUNNING, now) + member_rows = await _snapshot_member_posts( + conn, + locked["analysis_source_snapshot_id"], + ) + if member_rows: + rows = member_rows + else: + rows = await _cutoff_source_posts( + conn, + corporate_entity_id=locked["corporate_entity_id"], + knowledge_cutoff=locked["knowledge_cutoff"], + affiliated_entity_ids=affiliated_entity_ids, + ) + edges = lineage_edge_specs(records_from_source_posts(rows)) + digest = reconstruction_result_digest(edges) + finished = datetime.now(timezone.utc) + if finished < now: + finished = now + await conn.execute( + """ + insert into analysis_run_reconstruction + (analysis_run_id, result_sha256, edge_count, reconstructed_at) + values ($1, $2, $3, $4) + """, + analysis_run_id, + digest, + len(edges), + finished, + ) + for edge in edges: + await conn.execute( + """ + insert into analysis_run_lineage_edge + (analysis_run_id, child_post_id, parent_post_id, + fused_score, reconstructed_at) + values ($1, $2, $3, $4, $5) + """, + analysis_run_id, + edge.child_id, + edge.parent_id, + edge.fused_score, + finished, + ) + await _append_status( + conn, + analysis_run_id, + running_ordinal + 1, + _SUCCEEDED, + finished, + ) + except asyncpg.UniqueViolationError as exc: + raise start_write_conflict_error() from exc + started = await fetch_visible_analysis_run( + conn, + analysis_run_id, + account_id, + affiliated_entity_ids, + ) + if started is None: + raise AnalysisRunStartError(404, "This analysis run is not visible.") + return started diff --git a/backend/app/main.py b/backend/app/main.py index adb7a20a..b3da2497 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -72,6 +72,10 @@ fetch_visible_analysis_run, fetch_visible_analysis_runs, ) +from backend.app.analysis_run_start import ( + AnalysisRunStartError, + start_pending_analysis_run, +) from backend.app.activity_stream import ( create_valkey_client, get_valkey, @@ -1253,6 +1257,33 @@ async def create_analysis_run( return created +@app.post("/api/analysis-runs/{analysis_run_id}/start") +async def start_analysis_run( + analysis_run_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Start ThreadWeave on a visible Pending lineage run. + + post_read is enough. Hidden runs 404. TEPP and period-report are 422 + so this path cannot invent a theta. A Succeeded retry returns the + stored tree. A Running restart is 409. + """ + _require_post_read(account) + async with pool.acquire() as conn: + async with conn.transaction(): + try: + started = await start_pending_analysis_run( + conn, + analysis_run_id=analysis_run_id, + account_id=account.user_account_id, + affiliated_entity_ids=list(account.corporate_entity_ids), + ) + except AnalysisRunStartError as exc: + raise HTTPException(exc.status_code, exc.detail) from exc + return started + + @app.get("/api/analysis-runs/{analysis_run_id}") async def read_analysis_run( analysis_run_id: str, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3b74c22a..ef147e99 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -33,6 +33,12 @@ _MIGRATION_PATH = Path(__file__).resolve().parents[2] / "migrations" / "0001_initial_schema.sql" _REGISTRY_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0018_analysis_run_registry.sql" _RETENTION_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0020_analysis_run_retention_purge.sql" +_RECONSTRUCTION_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0021_analysis_run_reconstruction.sql" +) +_SNAPSHOT_MEMBER_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0022_analysis_source_snapshot_member.sql" +) def _postgres_available() -> bool: @@ -117,6 +123,8 @@ def seeded_db(demo_analyst_token): cur.execute(_MIGRATION_PATH.read_text()) cur.execute(_REGISTRY_MIGRATION.read_text()) cur.execute(_RETENTION_MIGRATION.read_text()) + cur.execute(_RECONSTRUCTION_MIGRATION.read_text()) + cur.execute(_SNAPSHOT_MEMBER_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -575,6 +583,216 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( assert unauthenticated.status_code == 401 +def test_start_analysis_run_recovers_the_a100_fork( + client, demo_analyst_token, seeded_db +) -> None: + """Starting a Pending lineage run persists the designed fixture tree.""" + from scripts.seed_demo_data import insert_fixture_source_posts + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) " + "values ('voc_type', 'vom', 'Voice of Market') " + "on conflict (lookup_code) do nothing" + ) + cur.execute( + "insert into process_unit (corporate_entity_id, process_unit_code, process_unit_name) " + "select corporate_entity_id, 'TEST-PU-START', 'Start reconstruction' " + "from source_post where post_id = %s returning process_unit_id", + (seeded_db["own_private_post_id"],), + ) + process_unit_id = cur.fetchone()[0] + cur.execute( + "select author_account_id, corporate_entity_id from source_post where post_id = %s", + (seeded_db["own_private_post_id"],), + ) + author_id, corp_id = cur.fetchone() + insert_fixture_source_posts(cur, author_id, corp_id, process_unit_id) + finally: + admin_conn.close() + + created = 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-02-15T00:00:00Z", + "idempotency_key": "buyer-start-2026-w07", + }, + ) + assert created.status_code == 201, created.text + run_id = created.json()["analysis_run_id"] + assert created.json()["status_label"] == "Pending" + + started = client.post( + f"/api/analysis-runs/{run_id}/start", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert started.status_code == 200, started.text + body = started.json() + assert body["status_label"] == "Succeeded" + assert all(event["status_label"] != "Failed" for event in body["status_history"]) + assert body["reconstruction_result_sha256"] + children = { + edge["child_post_title"] + for edge in body["reconstructed_edges"] + if edge["parent_post_title"] == "Pricing renegotiation follow-up" + } + assert "Pricing renegotiation: revised quote sent" in children + assert "Delivery schedule question raised" in children + assert "theta" not in str(body).lower() + + replay = client.post( + f"/api/analysis-runs/{run_id}/start", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert replay.status_code == 200 + assert replay.json()["reconstruction_result_sha256"] == body["reconstruction_result_sha256"] + + 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"], + "knowledge_cutoff": "2026-02-15T00:00:00Z", + "idempotency_key": "buyer-start-tepp-2026-w07", + }, + ) + assert tepp.status_code == 201 + refused = client.post( + f"/api/analysis-runs/{tepp.json()['analysis_run_id']}/start", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert refused.status_code == 422 + assert "invent a measurement" in refused.json()["detail"] + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'source-contract-v1', + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + ("9" * 64,), + ) + report_snapshot_id = cur.fetchone()[0] + 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_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, 'analysis_run_report', 'buyer-start-report', + %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, + '2026-01-12T12:30:00Z') + returning analysis_run_id + """, + (report_snapshot_id, requester_id, "8" * 64, "7" * 40), + ) + report_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) + """, + (report_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-01-12T12:31:00Z') + """, + (report_run_id,), + ) + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'source-contract-v1', + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + ("6" * 64,), + ) + running_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_lineage', 'buyer-start-running', + %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, + '2026-01-12T12:30:00Z') + returning analysis_run_id + """, + (running_snapshot_id, requester_id, "5" * 64, "4" * 40), + ) + running_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) + """, + (running_run_id, seeded_db["own_corp_id"]), + ) + for ordinal, status, occurred in ( + (1, "analysis_status_pending", "2026-01-12T12:31:00Z"), + (2, "analysis_status_running", "2026-01-12T12:32:00Z"), + ): + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values (%s, %s, %s, %s) + """, + (running_run_id, ordinal, status, occurred), + ) + finally: + admin_conn.close() + + report_refused = client.post( + f"/api/analysis-runs/{report_run_id}/start", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert report_refused.status_code == 422 + assert "invent a measurement" in report_refused.json()["detail"] + + running = client.post( + f"/api/analysis-runs/{running_run_id}/start", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert running.status_code == 409 + + hidden = client.post( + f"/api/analysis-runs/{seeded_db['hidden_run_id']}/start", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert hidden.status_code == 404 + + def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> None: response = client.get("/api/me", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index ce2f0e6b..71e9fc73 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -26,6 +26,8 @@ COPY migrations/0017_prov_o_standard_relations.sql /docker-entrypoint-initdb.d/1 COPY migrations/0018_analysis_run_registry.sql /docker-entrypoint-initdb.d/19-analysis-run-registry.sql COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-role-catalog-identity.sql COPY migrations/0020_analysis_run_retention_purge.sql /docker-entrypoint-initdb.d/21-analysis-run-retention-purge.sql +COPY migrations/0021_analysis_run_reconstruction.sql /docker-entrypoint-initdb.d/22-analysis-run-reconstruction.sql +COPY migrations/0022_analysis_source_snapshot_member.sql /docker-entrypoint-initdb.d/23-analysis-source-snapshot-member.sql # Official image already drops to this account at runtime; declare it so # the Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER postgres diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index d13994bc..afc60189 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -239,8 +239,11 @@ Acceptance requires: 1. Add a transaction repository that creates snapshot, counts, run, scope, and first status atomically and compares request digests on idempotent retries. - `POST /api/analysis-runs` now records that Pending write (ADR 0017); - reconstruction and live TEPP execution remain later slices. + `POST /api/analysis-runs` now records that Pending write (ADR 0017). + `POST /api/analysis-runs/{id}/start` now reconstructs a Pending + lineage cutoff bag in-process from frozen snapshot membership + (ADR 0021). A durable outbox / Valkey worker and live TEPP execution + remain later slices. 2. Add RBAC/ABAC-protected run list/detail endpoints and the DB-grounded read-only administrator surface. 3. Add a normalized PostgreSQL outbox and Valkey delivery worker. diff --git a/docs/adr/0017-authorized-analysis-run-create.md b/docs/adr/0017-authorized-analysis-run-create.md index e3a535a1..be732887 100644 --- a/docs/adr/0017-authorized-analysis-run-create.md +++ b/docs/adr/0017-authorized-analysis-run-create.md @@ -22,7 +22,8 @@ still owns reconstruction and live TEPP execution. they already walk. An unaffiliated corp is 404, not 403. - The capture digest hashes scope, entity, cutoff, and authorized post ids — never a post body, DSN, or source SQL. -- The write inserts snapshot, aggregate counts, `analysis_run`, +- 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 @@ -35,9 +36,10 @@ still owns reconstruction and live TEPP execution. ## 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. +Pending row the operator can open immediately. `POST +/api/analysis-runs/{id}/start` then reconstructs that frozen bag +(ADR 0021). TEPP transport and the outbox worker remain later slices. +Do not stamp Succeeded or invent a theta from this write. ## References — APA 7th diff --git a/docs/adr/0021-authorized-analysis-run-start.md b/docs/adr/0021-authorized-analysis-run-start.md new file mode 100644 index 00000000..61db3667 --- /dev/null +++ b/docs/adr/0021-authorized-analysis-run-start.md @@ -0,0 +1,112 @@ +# ADR 0021 — Operators start a pending lineage reconstruction + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-16 +**Depends on:** ADR 0013 registry; ADR 0014 authorized read; ADR 0016 cutoff +posts; ADR 0017 authorized create +**Refs:** Issue #79 (Milestone 2 parent); ADR 0013 follow-up 3 (in-process +start; durable outbox remains later) + +## Context + +ADR 0017 let an operator record a Pending analysis run. The home button +said “Request a lineage reconstruction,” then the row stayed Pending. +Seed still owned the only Succeeded Demo Corp tree. A buyer cannot +treat a request they cannot start as a product. + +ADR 0013 follow-up 3 asked for a PostgreSQL outbox and Valkey worker. +That durable delivery path is still later. This slice starts +reconstruction in the authorized request so the operator can see the +cutoff tree immediately. A crash after Running and before Succeeded +rolls the transaction back to Pending. + +Landed #145 occupies ADR 0020 / package 0.87.0 for granted retention +purge. ADR 0019 binds R&R catalog identity. This decision is the next +free slot. + +## Decision + +`POST /api/analysis-runs/{id}/start` requires `post_read` and, in one +transaction: + +1. loads the authorized run (hidden scopes 404); +2. rejects non-lineage kinds so TEPP and period-report cannot invent a + theta or a calibrated score; +3. replays a Succeeded run (documented no-op; same stored digest); +4. accepts only Pending lineage — Running is 409; +5. locks the run row, re-reads status, appends Running, runs + `lineage_edge_specs` / `reconstruct()` on the frozen + `analysis_source_snapshot_member` bag (or the live cutoff query when + membership was never persisted), persists + `analysis_run_reconstruction` plus `analysis_run_lineage_edge`, then + appends Succeeded. A concurrent start is 409 with a refresh next + action, not a 500. + +```mermaid +sequenceDiagram + participant Operator + participant API + participant ThreadWeave + participant Registry + Operator->>API: POST /api/analysis-runs/{id}/start + API->>Registry: lock visible Pending lineage run + alt TEPP or period-report + API-->>Operator: 422 use the kind's own path + else already Succeeded + Registry-->>API: stored edges + API-->>Operator: 200 replay + else Running + API-->>Operator: 409 refresh + else Pending lineage + Registry->>Registry: Running + API->>ThreadWeave: reconstruct frozen bag + ThreadWeave-->>API: parent choices + Registry->>Registry: reconstruction + edges + Succeeded + API-->>Operator: 200 titled edges + end +``` + +Rules: + +- Edges are run-scoped. This write does not replace live + `post_lineage_edge` (the Event Lineage panel stays a later rebuild). +- The digest hashes parent id, child id, and rounded fused score — never + a post body, DSN, or image. +- Empty cutoff bags Succeed with zero edges. +- Failed TEPP remains a `tepp_client` transport problem. +- Create freezes authorized post ids on + `analysis_source_snapshot_member` so start cannot pick up a later + backfill that shares the cutoff clock. + +The home detail adds **Start reconstruction** on a Pending lineage row +and lists titled parent→child edges after Succeeded. The Result digest +prefix is audible next to Code and Config; hover it to verify the +parent-choice hash. Edge titles stay public-or-affiliated. TEPP and +period-report rows do not show the button. + +## Consequences + +Demo Analyst can request a run, start it, and confirm the designed A-100 +fork (revised quote and delivery question under the pricing follow-up) +without a seed-only Succeeded row. The durable outbox / Valkey worker +and live TEPP transport remain later slices. Do not stamp Succeeded +from a missing reconstruct library, and do not invent a theta. + +## References — APA 7th + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +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 + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. +World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C +Recommendation). https://www.w3.org/TR/prov-o/ + +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 c776053b..6d3427fa 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -1,7 +1,8 @@ # Analysis-run registry standards and research traceability **Status:** Active PR evidence; not protected-main truth until merge. -**Scope:** Migrations 0018 and 0020, ADR 0013 / 0020, rollback, and real-PostgreSQL contract tests. +**Scope:** Migrations 0018–0022, ADR 0013 / 0017 / 0020 / 0021, rollback, and +real-PostgreSQL contract tests. ## Standards mapped to implementation @@ -14,7 +15,8 @@ | 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` / `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. | +| ThreadWeave tree assembly | Persist the same parent choices the library reconstructs on the cutoff bag. | `start_pending_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). | ## Temporal reasoning @@ -77,12 +79,16 @@ provenance, retention, and immutable evidence rather than blanket masking. | Idempotency is actor-scoped | Permit identical opaque keys for two accounts and reject reuse by the same account. | | Lifecycle is ordered | Require pending first, contiguous ordinals, monotonic time, legal transitions, terminal finality, and append-only rows. | | Rollback does not erase audit data silently | Reject 0018 rollback with any registry rows. A run-bearing registry empties only through an unrevoked `analysis_run_retention_grant` plus `analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')`; a wrong token, a raw `DELETE`, and a runtime role that only knows the public phrase stay rejected. Export then delete `analysis_run_retention_event` before 0020 rollback. | +| Start reconstruction recovers the designed tree | Persist edges from `lineage_edge_specs` on the A-100 fixture bag via `records_from_source_posts`; the pricing follow-up must parent both the revised quote and the delivery question. A TEPP or period-report start must 422 without a theta. Snapshot members exclude a later backfill. A concurrent or Running start is 409. A Succeeded retry returns the stored digest. | ## APA 7th references American Institute of Certified Public Accountants. (2017). *SOC 2®: SOC for Service Organizations: Trust Services Criteria*. +ContextualWisdomLab. (2026). *ThreadWeave* [Computer software]. +https://github.com/ContextualWisdomLab/ThreadWeave + International Organization for Standardization. (2016). *ISO 15489-1:2016: Information and documentation—Records management—Part 1: Concepts and principles*. diff --git a/frontend/package.json b/frontend/package.json index 0d43d9fa..4c66c720 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.87.0", + "version": "0.88.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fd8a1514..b0669325 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -329,6 +329,61 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/analysis-runs/run-demo-lineage-pending/start") && method === "POST") { + return Promise.resolve( + jsonResponse({ + analysis_run_id: "run-demo-lineage-pending", + run_kind_code: "analysis_run_lineage", + run_kind_label: "Lineage reconstruction", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_succeeded", + status_label: "Succeeded", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:35:00Z", + source_counts: [], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + reconstructed_edges: [ + { + parent_post_id: "post-follow-up", + parent_post_title: "Pricing renegotiation follow-up", + child_post_id: "post-quote", + child_post_title: "Pricing renegotiation: revised quote sent", + fused_score: 0.72, + }, + { + parent_post_id: "post-follow-up", + parent_post_title: "Pricing renegotiation follow-up", + child_post_id: "post-delivery", + child_post_title: "Delivery schedule question raised", + fused_score: 0.68, + }, + ], + reconstruction_result_sha256: "aa".repeat(32), + status_history: [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + { + status_ordinal: 3, + status_code: "analysis_status_succeeded", + status_label: "Succeeded", + occurred_at: "2026-01-12T12:37:00Z", + }, + ], + }), + ); + } if (url.endsWith("/api/analysis-runs") && method === "POST") { const created = { analysis_run_id: "run-demo-lineage-pending", @@ -343,6 +398,7 @@ describe("App, authenticated", () => { requested_at: "2026-01-12T12:35:00Z", source_counts: [], visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + reconstructed_edges: [], status_history: [ { status_ordinal: 1, @@ -1754,6 +1810,7 @@ describe("App, authenticated", () => { expect(reportButton).not.toHaveTextContent("reconstruction"); await userEvent.click(reportButton); + expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument(); expect( await screen.findByText( "No posts were available at this cutoff for the period report. Open a later run, or ask an administrator to capture a newer snapshot.", @@ -1776,6 +1833,7 @@ describe("App, authenticated", () => { expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument(); }); it("does not tell a succeeded TEPP run to replace Failed", async () => { @@ -1810,9 +1868,10 @@ describe("App, authenticated", () => { ).toBeInTheDocument(); expect( screen.getAllByText( - "Open this run to confirm which posts it will use. Reconstruction has not started yet.", + "Open this run, then start reconstruction. Reconstruction has not started yet.", ), ).toHaveLength(2); + expect(screen.getByRole("button", { name: "Start reconstruction" })).toBeInTheDocument(); const postCall = fetchMock.mock.calls.find( (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", ); @@ -1824,6 +1883,34 @@ describe("App, authenticated", () => { ); }); + it("starts reconstruction and shows the designed A-100 fork", async () => { + const fetchMock = stubBackend(); + render(); + + await userEvent.click( + await screen.findByRole("button", { name: "Request a lineage reconstruction" }), + ); + await userEvent.click(await screen.findByRole("button", { name: "Start reconstruction" })); + expect( + await screen.findByRole("heading", { name: "Lineage reconstruction · Succeeded · Demo Corp" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Pricing renegotiation: revised quote sent follows Pricing renegotiation follow-up", + ), + ).toBeInTheDocument(); + expect( + screen.getByText("Delivery schedule question raised follows Pricing renegotiation follow-up"), + ).toBeInTheDocument(); + const digests = screen.getByLabelText("Analysis run reproducibility digests"); + expect(digests).toHaveTextContent("Result aaaaaaaaaaaa"); + expect(screen.getByTitle("aa".repeat(32))).toHaveTextContent("Result aaaaaaaaaaaa"); + const startCall = fetchMock.mock.calls.find((call) => + String(call[0]).endsWith("/api/analysis-runs/run-demo-lineage-pending/start"), + ); + expect(startCall?.[1]?.method).toBe("POST"); + }); + 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 07088e9d..0296fb6b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { askPostChat, BackendError, createAnalysisRun, + startAnalysisRun, createPostTicket, deriveCommitment, evaluatePost, @@ -1459,7 +1460,7 @@ function analysisRunNextAction(run: AnalysisRun): string | null { case "analysis_status_pending": switch (run.run_kind_code) { case "analysis_run_lineage": - return "Open this run to confirm which posts it will use. Reconstruction has not started yet."; + return "Open this run, then start reconstruction. Reconstruction has not started yet."; case "analysis_run_tepp": return "Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result."; case "analysis_run_report": @@ -1583,11 +1584,23 @@ function analysisRunLivePostButtonLabel(postTitle: string): string { function AnalysisRunReproducibilityDigests({ codeRevisionSha, configurationSha256, + reconstructionResultSha256, }: { codeRevisionSha?: string; configurationSha256?: string; + reconstructionResultSha256?: string; }) { - if (!codeRevisionSha && !configurationSha256) { + const parts: { label: string; digest: string }[] = []; + if (codeRevisionSha) { + parts.push({ label: "Code", digest: codeRevisionSha }); + } + if (configurationSha256) { + parts.push({ label: "Config", digest: configurationSha256 }); + } + if (reconstructionResultSha256) { + parts.push({ label: "Result", digest: reconstructionResultSha256 }); + } + if (parts.length === 0) { return null; } return ( @@ -1596,20 +1609,29 @@ function AnalysisRunReproducibilityDigests({ Hover a prefix to read the full digest for verification.{" "} - {codeRevisionSha ? ( - {`Code ${analysisRunDigestPrefix(codeRevisionSha)}`} - ) : null} - {codeRevisionSha && configurationSha256 ? " · " : null} - {configurationSha256 ? ( - - {`Config ${analysisRunDigestPrefix(configurationSha256)}`} + {parts.map((part, index) => ( + + {index > 0 ? " · " : null} + {`${part.label} ${analysisRunDigestPrefix(part.digest)}`} - ) : null} + ))}

          ); } +/** + * Start is only for a Pending Demo Corp lineage row after Request. + * + * TEPP and period-report keep their own transports. This button must + * not appear on those kinds. + */ +function analysisRunCanStartReconstruction(run: AnalysisRun): boolean { + return ( + run.run_kind_code === "analysis_run_lineage" && run.status_code === "analysis_status_pending" + ); +} + function AnalysisRunsPanel({ accessToken, onSelectPost, @@ -1621,6 +1643,7 @@ function AnalysisRunsPanel({ const [selected, setSelected] = useState(null); const [error, setError] = useState(null); const [requesting, setRequesting] = useState(false); + const [starting, setStarting] = useState(false); useEffect(() => { fetchAnalysisRuns(accessToken) @@ -1646,6 +1669,22 @@ function AnalysisRunsPanel({ } } + async function handleStartReconstruction() { + if (!selected) return; + setError(null); + setStarting(true); + try { + const started = await startAnalysisRun(accessToken, selected.analysis_run_id); + const listed = await fetchAnalysisRuns(accessToken); + setRuns(listed.analysis_runs); + setSelected(started); + } catch (err) { + setError(err instanceof BackendError ? err.message : String(err)); + } finally { + setStarting(false); + } + } + async function handleOpen(runId: string) { setError(null); try { @@ -1725,7 +1764,27 @@ function AnalysisRunsPanel({ + {analysisRunCanStartReconstruction(selected) && ( + + )} + {selected.reconstructed_edges && selected.reconstructed_edges.length > 0 && ( +
            + {selected.reconstructed_edges.map((edge) => ( +
          • + {edge.child_post_title} follows {edge.parent_post_title} +
          • + ))} +
          + )}
            {selected.source_counts.map((count) => (
          • diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3385d517..0213c3fc 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -536,6 +536,14 @@ export interface AnalysisRunStatusEvent { failure_code?: string; } +export interface AnalysisRunReconstructedEdge { + parent_post_id: string; + parent_post_title: string; + child_post_id: string; + child_post_title: string; + fused_score: number; +} + export interface AnalysisRun { analysis_run_id: string; run_kind_code: AnalysisRunKindCode; @@ -550,6 +558,8 @@ export interface AnalysisRun { source_counts: AnalysisRunCount[]; status_history?: AnalysisRunStatusEvent[]; visible_posts?: { post_id: string; post_title: string }[]; + reconstructed_edges?: AnalysisRunReconstructedEdge[]; + reconstruction_result_sha256?: string; code_revision_sha?: string; configuration_sha256?: string; } @@ -579,3 +589,12 @@ export function createAnalysisRun( body: JSON.stringify(request), }); } + +export function startAnalysisRun( + accessToken: string, + analysisRunId: string, +): Promise { + return backendFetch(`/api/analysis-runs/${analysisRunId}/start`, accessToken, { + method: "POST", + }); +} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1950c39f..036dca1f 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.87.0" +__version__ = "0.88.0" diff --git a/migrations/0021_analysis_run_reconstruction.sql b/migrations/0021_analysis_run_reconstruction.sql new file mode 100644 index 00000000..8c66a584 --- /dev/null +++ b/migrations/0021_analysis_run_reconstruction.sql @@ -0,0 +1,79 @@ +-- Run-scoped lineage reconstruction result (ADR 0021). +-- +-- A Pending analysis run may later persist the ThreadWeave parent choices +-- for its cutoff bag. Edges belong to the run, not the live Event Lineage +-- panel. No post body, DSN, or fabricated measurement is stored. + +create table if not exists analysis_run_reconstruction ( + analysis_run_id uuid primary key + references analysis_run (analysis_run_id), + result_sha256 text not null, + edge_count integer not null, + reconstructed_at timestamptz not null, + recorded_at timestamptz not null default clock_timestamp(), + constraint analysis_run_reconstruction_digest_check + check (result_sha256 ~ '^[0-9a-f]{64}$'), + constraint analysis_run_reconstruction_edge_count_check + check (edge_count >= 0), + constraint analysis_run_reconstruction_time_check + check (reconstructed_at <= recorded_at) +); + +comment on table analysis_run_reconstruction is + 'One immutable reconstruction digest per analysis run; never a post body ' + 'or a fabricated psychometric score.'; + +create table if not exists analysis_run_lineage_edge ( + analysis_run_id uuid not null + references analysis_run_reconstruction (analysis_run_id), + child_post_id uuid not null + references source_post (post_id), + parent_post_id uuid not null + references source_post (post_id), + fused_score double precision not null, + reconstructed_at timestamptz not null, + primary key (analysis_run_id, child_post_id), + constraint analysis_run_lineage_edge_distinct_check + check (child_post_id <> parent_post_id), + constraint analysis_run_lineage_edge_score_check + check (fused_score >= 0 and fused_score <= 1) +); + +comment on table analysis_run_lineage_edge is + 'One reconstructed parent choice per child post inside one analysis run.'; + +create or replace function reject_analysis_run_reconstruction_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_reconstruction_is_immutable'; +end +$$; + +comment on function reject_analysis_run_reconstruction_update() is + 'Rejects mutation of a persisted reconstruction digest.'; + +drop trigger if exists analysis_run_reconstruction_update_reject + on analysis_run_reconstruction; +create trigger analysis_run_reconstruction_update_reject +before update or delete on analysis_run_reconstruction +for each row execute function reject_analysis_run_reconstruction_update(); + +create or replace function reject_analysis_run_lineage_edge_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_lineage_edge_is_immutable'; +end +$$; + +comment on function reject_analysis_run_lineage_edge_update() is + 'Rejects mutation of a persisted run-scoped lineage edge.'; + +drop trigger if exists analysis_run_lineage_edge_update_reject + on analysis_run_lineage_edge; +create trigger analysis_run_lineage_edge_update_reject +before update or delete on analysis_run_lineage_edge +for each row execute function reject_analysis_run_lineage_edge_update(); diff --git a/migrations/0022_analysis_source_snapshot_member.sql b/migrations/0022_analysis_source_snapshot_member.sql new file mode 100644 index 00000000..1ea20fc3 --- /dev/null +++ b/migrations/0022_analysis_source_snapshot_member.sql @@ -0,0 +1,35 @@ +-- Create-time cutoff membership for an analysis source snapshot (ADR 0021). +-- +-- The snapshot digest already hashes authorized post ids. This relation +-- stores those ids so start reconstructs the same bag, not a later +-- backfill that shares the cutoff clock. No post body is stored. + +create table if not exists analysis_source_snapshot_member ( + analysis_source_snapshot_id uuid not null + references analysis_source_snapshot (analysis_source_snapshot_id), + source_post_id uuid not null + references source_post (post_id), + primary key (analysis_source_snapshot_id, source_post_id) +); + +comment on table analysis_source_snapshot_member is + 'Authorized post ids frozen at snapshot capture; start reconstructs ' + 'these rows and never a later backfill.'; + +create or replace function reject_analysis_source_snapshot_member_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_source_snapshot_member_is_immutable'; +end +$$; + +comment on function reject_analysis_source_snapshot_member_update() is + 'Rejects mutation of frozen snapshot membership.'; + +drop trigger if exists analysis_source_snapshot_member_update_reject + on analysis_source_snapshot_member; +create trigger analysis_source_snapshot_member_update_reject +before update or delete on analysis_source_snapshot_member +for each row execute function reject_analysis_source_snapshot_member_update(); diff --git a/migrations/rollback/0021_analysis_run_reconstruction.sql b/migrations/rollback/0021_analysis_run_reconstruction.sql new file mode 100644 index 00000000..3042eecf --- /dev/null +++ b/migrations/rollback/0021_analysis_run_reconstruction.sql @@ -0,0 +1,37 @@ +-- Fail-closed rollback for migration 0021. +-- +-- Reconstruction evidence must be exported or explicitly deleted under an +-- approved retention procedure before these objects can be removed. + +begin; + +do $$ +declare + relation_name text; + relation_has_rows boolean; +begin + foreach relation_name in array array[ + 'analysis_run_lineage_edge', + 'analysis_run_reconstruction' + ] loop + if to_regclass('public.' || relation_name) is not null then + execute format('select exists (select 1 from %I)', relation_name) + into relation_has_rows; + if relation_has_rows then + raise exception 'analysis_run_reconstruction_not_empty'; + end if; + end if; + end loop; +end +$$; + +drop trigger if exists analysis_run_lineage_edge_update_reject + on analysis_run_lineage_edge; +drop trigger if exists analysis_run_reconstruction_update_reject + on analysis_run_reconstruction; +drop function if exists reject_analysis_run_lineage_edge_update(); +drop function if exists reject_analysis_run_reconstruction_update(); +drop table if exists analysis_run_lineage_edge; +drop table if exists analysis_run_reconstruction; + +commit; diff --git a/migrations/rollback/0022_analysis_source_snapshot_member.sql b/migrations/rollback/0022_analysis_source_snapshot_member.sql new file mode 100644 index 00000000..c51a55d5 --- /dev/null +++ b/migrations/rollback/0022_analysis_source_snapshot_member.sql @@ -0,0 +1,27 @@ +-- Fail-closed rollback for migration 0022. +-- +-- Snapshot membership must be exported or explicitly deleted under an +-- approved retention procedure before these objects can be removed. + +begin; + +do $$ +declare + relation_has_rows boolean; +begin + if to_regclass('public.analysis_source_snapshot_member') is not null then + execute 'select exists (select 1 from analysis_source_snapshot_member)' + into relation_has_rows; + if relation_has_rows then + raise exception 'analysis_source_snapshot_member_not_empty'; + end if; + end if; +end +$$; + +drop trigger if exists analysis_source_snapshot_member_update_reject + on analysis_source_snapshot_member; +drop function if exists reject_analysis_source_snapshot_member_update(); +drop table if exists analysis_source_snapshot_member; + +commit; diff --git a/pyproject.toml b/pyproject.toml index ecfe2487..5a4aa12b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.87.0" +version = "0.88.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 2f3c66c4..8cb1f0ea 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -122,6 +122,8 @@ def seed( cur.execute((migrations / "0018_analysis_run_registry.sql").read_text()) cur.execute((migrations / "0019_role_catalog_identity.sql").read_text()) cur.execute((migrations / "0020_analysis_run_retention_purge.sql").read_text()) + cur.execute((migrations / "0021_analysis_run_reconstruction.sql").read_text()) + cur.execute((migrations / "0022_analysis_source_snapshot_member.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values @@ -1283,6 +1285,35 @@ def _ensure_demo_source_counts(cur, snapshot_id) -> None: ) +def _ensure_demo_source_snapshot_members(cur, snapshot_id, corporate_entity_id) -> None: + """Freeze Demo Corp post ids on the shared snapshot when the table exists.""" + cur.execute( + "select 1 from information_schema.tables " + "where table_schema = 'public' " + "and table_name = 'analysis_source_snapshot_member'" + ) + if cur.fetchone() is None: + return + cur.execute( + "select 1 from analysis_source_snapshot_member " + "where analysis_source_snapshot_id = %s limit 1", + (snapshot_id,), + ) + if cur.fetchone() is not None: + return + cur.execute( + """ + insert into analysis_source_snapshot_member + (analysis_source_snapshot_id, source_post_id) + select %s, post_id from source_post + where corporate_entity_id = %s + and created_at <= '2026-01-12T00:00:00Z' + on conflict do nothing + """, + (snapshot_id, corporate_entity_id), + ) + + def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None: """Insert one Demo-Corp lineage run so Analysis runs is not empty. @@ -1292,6 +1323,7 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - """ snapshot_id = _ensure_demo_source_snapshot(cur) _ensure_demo_source_counts(cur, snapshot_id) + _ensure_demo_source_snapshot_members(cur, snapshot_id, corporate_entity_id) cur.execute( """ select analysis_run_id from analysis_run @@ -1387,6 +1419,7 @@ def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> No """ snapshot_id = _ensure_demo_source_snapshot(cur) _ensure_demo_source_counts(cur, snapshot_id) + _ensure_demo_source_snapshot_members(cur, snapshot_id, corporate_entity_id) cur.execute( """ select analysis_run_id from analysis_run diff --git a/tests/test_analysis_run_reconstruction_schema.py b/tests/test_analysis_run_reconstruction_schema.py new file mode 100644 index 00000000..8ca265f3 --- /dev/null +++ b/tests/test_analysis_run_reconstruction_schema.py @@ -0,0 +1,150 @@ +"""Static and optional PostgreSQL contracts for run-scoped reconstruction.""" + +from __future__ import annotations + +import os +import re +import uuid +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" +_REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" +_RECONSTRUCTION_MIGRATION = _ROOT / "migrations" / "0021_analysis_run_reconstruction.sql" +_RECONSTRUCTION_ROLLBACK = ( + _ROOT / "migrations" / "rollback" / "0021_analysis_run_reconstruction.sql" +) +_SNAPSHOT_MEMBER_MIGRATION = ( + _ROOT / "migrations" / "0022_analysis_source_snapshot_member.sql" +) +_SNAPSHOT_MEMBER_ROLLBACK = ( + _ROOT / "migrations" / "rollback" / "0022_analysis_source_snapshot_member.sql" +) +_POSTGRES_IMAGE = _ROOT / "docker" / "postgres-init" / "Dockerfile" +_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" +) +_REQUIRED_TABLES = { + "analysis_run_reconstruction", + "analysis_run_lineage_edge", +} + + +def test_reconstruction_migration_is_normalized_and_wired() -> None: + """Static contract: 3NF names, no payload JSON, Dockerfile copy, rollback.""" + migration = _RECONSTRUCTION_MIGRATION.read_text(encoding="utf-8") + rollback = _RECONSTRUCTION_ROLLBACK.read_text(encoding="utf-8") + dockerfile = _POSTGRES_IMAGE.read_text(encoding="utf-8") + created_tables = set( + re.findall(r"create table if not exists\s+([a-z0-9_]+)", migration, re.I) + ) + assert _REQUIRED_TABLES <= created_tables + assert "jsonb" not in migration.casefold() + assert "metadata_payload" not in migration + assert "theta" not in migration.casefold() + assert "0021_analysis_run_reconstruction.sql" in dockerfile + assert "0022_analysis_source_snapshot_member.sql" in dockerfile + assert "analysis_run_reconstruction_not_empty" in rollback + assert "reject_analysis_run_reconstruction_update" in migration + assert "reject_analysis_run_lineage_edge_update" in migration + member_migration = _SNAPSHOT_MEMBER_MIGRATION.read_text(encoding="utf-8") + member_rollback = _SNAPSHOT_MEMBER_ROLLBACK.read_text(encoding="utf-8") + assert "analysis_source_snapshot_member" in member_migration + assert "jsonb" not in member_migration.casefold() + assert "theta" not in member_migration.casefold() + assert "analysis_source_snapshot_member_not_empty" in member_rollback + assert "reject_analysis_source_snapshot_member_update" in member_migration + for object_name in re.findall( + r"create table if not exists\s+([a-z0-9_]+)", + member_migration, + re.I, + ): + assert len(object_name.split("_")) >= 2, object_name + + object_patterns = ( + r"create table if not exists\s+([a-z0-9_]+)", + r"create or replace function\s+([a-z0-9_]+)", + r"create trigger\s+([a-z0-9_]+)", + ) + for pattern in object_patterns: + for object_name in re.findall(pattern, migration, re.I): + assert len(object_name.split("_")) >= 2, object_name + + +def _postgres_available() -> bool: + """Return whether the configured administrator DSN is reachable.""" + try: + import psycopg2 + + psycopg2.connect(_ADMIN_DSN, connect_timeout=2).close() + return True + except Exception: + return False + + +def _database_dsn(database_name: str) -> str: + """Replace only the database path while preserving DSN query options.""" + parsed = urlsplit(_ADMIN_DSN) + return urlunsplit(parsed._replace(path=f"/{database_name}")) + + +@pytest.fixture +def reconstruction_db(): + """Yield a throwaway registry+reconstruction database.""" + if not _postgres_available(): + pytest.skip("a reachable PostgreSQL administrator DSN is required") + import psycopg2 + + database_name = f"lineageweave_recon_{uuid.uuid4().hex[:12]}" + admin = psycopg2.connect(_ADMIN_DSN) + admin.autocommit = True + try: + with admin.cursor() as cursor: + cursor.execute(f'create database "{database_name}"') + finally: + admin.close() + conn = psycopg2.connect(_database_dsn(database_name)) + conn.autocommit = True + try: + with conn.cursor() as cursor: + cursor.execute(_INITIAL_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_RECONSTRUCTION_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_SNAPSHOT_MEMBER_MIGRATION.read_text(encoding="utf-8")) + yield conn + finally: + conn.close() + admin = psycopg2.connect(_ADMIN_DSN) + admin.autocommit = True + try: + with admin.cursor() as cursor: + cursor.execute( + "select pg_terminate_backend(pid) from pg_stat_activity " + "where datname = %s and pid <> pg_backend_pid()", + (database_name,), + ) + cursor.execute(f'drop database "{database_name}"') + finally: + admin.close() + + +def test_empty_reconstruction_rollback_is_replayable(reconstruction_db) -> None: + """An empty reconstruction schema can be rolled back and removed.""" + with reconstruction_db.cursor() as cursor: + cursor.execute( + "select table_name from information_schema.tables " + "where table_schema = 'public' and table_name = any(%s)", + (list(_REQUIRED_TABLES),), + ) + assert {row[0] for row in cursor.fetchall()} == _REQUIRED_TABLES + cursor.execute(_RECONSTRUCTION_ROLLBACK.read_text(encoding="utf-8")) + cursor.execute( + "select table_name from information_schema.tables " + "where table_schema = 'public' and table_name = any(%s)", + (list(_REQUIRED_TABLES),), + ) + assert cursor.fetchall() == [] + cursor.execute(_RECONSTRUCTION_ROLLBACK.read_text(encoding="utf-8")) diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index 3d185dbe..6041d309 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -276,10 +276,18 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non assert "0018_analysis_run_registry.sql" in dockerfile assert "0019_role_catalog_identity.sql" in dockerfile assert "0020_analysis_run_retention_purge.sql" in dockerfile + assert "0021_analysis_run_reconstruction.sql" in dockerfile + assert "0022_analysis_source_snapshot_member.sql" in dockerfile seed = (_ROOT / "scripts" / "seed_demo_data.py").read_text(encoding="utf-8") assert seed.index("0019_role_catalog_identity.sql") < seed.index( "0020_analysis_run_retention_purge.sql" ) + assert seed.index("0020_analysis_run_retention_purge.sql") < seed.index( + "0021_analysis_run_reconstruction.sql" + ) + assert seed.index("0021_analysis_run_reconstruction.sql") < seed.index( + "0022_analysis_source_snapshot_member.sql" + ) assert "analysis_run_registry_not_empty" in rollback retention = _RETENTION_MIGRATION.read_text(encoding="utf-8") retention_rollback = _RETENTION_ROLLBACK.read_text(encoding="utf-8") diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py new file mode 100644 index 00000000..e7752b35 --- /dev/null +++ b/tests/test_analysis_run_start.py @@ -0,0 +1,119 @@ +"""Start-reconstruction contracts: digest, freeze, 422/409, designed tree.""" + +from backend.app.analysis_run_ingestion import reconstructed_edge_is_visible +from backend.app.analysis_run_start import ( + AnalysisRunStartError, + reconstruction_member_ids, + reconstruction_result_digest, + start_kind_rejection, + start_write_conflict_error, +) +from backend.app.lineage_ingestion import records_from_source_posts +from lineageweave.fixtures import sample_records +from lineageweave.lineage_persistence import lineage_edge_specs + + +def test_reconstruction_digest_is_stable_and_ignores_edge_order() -> None: + """The same parent choices hash the same way regardless of insert order.""" + edges = lineage_edge_specs(sample_records()) + reversed_edges = list(reversed(edges)) + assert reconstruction_result_digest(edges) == reconstruction_result_digest(reversed_edges) + assert reconstruction_result_digest([]) == reconstruction_result_digest([]) + assert reconstruction_result_digest(edges) != reconstruction_result_digest([]) + + +def test_start_uses_the_same_parent_choices_as_library_reconstruct() -> None: + """The product start path must recover the designed A-100 fork. + + fixtures.sample_records() is the synthetic gold tree: rec-002 is the + branch point for the revised quote and the delivery question. A start + that dropped an edge or invented a parent would fail this check. + """ + edges = lineage_edge_specs(sample_records()) + children = {edge.child_id for edge in edges if edge.parent_id == "rec-002"} + assert children >= {"rec-003", "rec-004"} + assert all(0.0 <= edge.fused_score <= 1.0 for edge in edges) + assert "theta" not in reconstruction_result_digest(edges) + + +def test_start_wiring_recovers_a100_from_source_post_rows() -> None: + """CI must exercise records_from_source_posts, not only library reconstruct.""" + rows = [ + { + "post_id": record.record_id, + "post_title": record.label, + "created_at": record.occurred_at, + "thread_group_key": record.group_key, + "secondary_grouping_key": record.secondary_key, + "process_unit_id": None, + "corporate_entity_id": "corp-demo", + } + for record in sample_records() + ] + edges = lineage_edge_specs(records_from_source_posts(rows)) + children = {edge.child_id for edge in edges if edge.parent_id == "rec-002"} + assert children >= {"rec-003", "rec-004"} + assert reconstruction_result_digest(edges) == reconstruction_result_digest( + lineage_edge_specs(sample_records()) + ) + + +def test_snapshot_members_exclude_a_later_backfill() -> None: + """Start reconstructs the create-time bag, not a later cutoff re-query.""" + captured = ["rec-001", "rec-002", "rec-003", "rec-004"] + cutoff_with_backfill = [*captured, "rec-backfill"] + assert reconstruction_member_ids(captured, cutoff_with_backfill) == captured + assert reconstruction_member_ids([], cutoff_with_backfill) == cutoff_with_backfill + + +def test_reconstructed_edge_hides_unaffiliated_private_titles() -> None: + """Edge titles use the same public-or-affiliated rule as cutoff posts.""" + affiliated = ["corp-demo"] + assert reconstructed_edge_is_visible( + parent_visibility_code="public", + parent_corporate_entity_id="corp-other", + child_visibility_code="public", + child_corporate_entity_id="corp-other", + affiliated_entity_ids=affiliated, + ) + assert not reconstructed_edge_is_visible( + parent_visibility_code="private", + parent_corporate_entity_id="corp-other", + child_visibility_code="public", + child_corporate_entity_id="corp-demo", + affiliated_entity_ids=affiliated, + ) + + +def test_tepp_and_period_report_start_are_unprocessable() -> None: + """TEPP and period-report start stay 422 so this path cannot invent a score.""" + tepp = start_kind_rejection("analysis_run_tepp") + assert tepp is not None + assert tepp.status_code == 422 + assert "invent a measurement" in tepp.detail + report = start_kind_rejection("analysis_run_report") + assert report is not None + assert report.status_code == 422 + assert "invent a measurement" in report.detail + assert "period report" in report.detail + assert start_kind_rejection("analysis_run_lineage") is None + + +def test_hidden_run_start_is_not_found() -> None: + """Operators get a 404 next action, not an internal exception name.""" + error = AnalysisRunStartError(404, "This analysis run is not visible.") + assert error.status_code == 404 + assert "not visible" in error.detail + + +def test_running_restart_conflicts_and_succeeded_replay_is_documented() -> None: + """Running is 409. Succeeded replay is a documented no-op (200 in the API).""" + conflict = start_write_conflict_error() + assert conflict.status_code == 409 + assert "Refresh to see the stored tree" in conflict.detail + running = AnalysisRunStartError( + 409, + "Open this run. Start is only for a Pending lineage reconstruction.", + ) + assert running.status_code == 409 + assert "Pending" in running.detail From e4d74616524a92a2ce4a7d51745a3dc4b875e87b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:08:28 +0900 Subject: [PATCH 118/161] feat: compare live post write clock with analysis-run cutoff (v0.89.0) (#193) Analysis-run detail marks in-cutoff titles rewritten after the run. Demo public post is the edited counter-example; Demo private post still matches the January cutoff. Bodies stay live. --- ARCHITECTURE.md | 5 +- .../0.89.0-analysis-run-live-write-clock.md | 5 ++ CHANGELOG.md | 12 +++++ CLAUDE.md | 5 +- backend/app/analysis_run_ingestion.py | 46 ++++++++++++++++--- backend/tests/test_api.py | 30 ++++++++++-- ...016-analysis-run-knowledge-cutoff-posts.md | 15 +++--- .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 2 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 31 +++++++++++-- frontend/src/App.tsx | 26 +++++++---- frontend/src/api.ts | 9 +++- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- scripts/seed_demo_data.py | 24 ++++++---- tests/test_analysis_run_create.py | 12 +++++ uv.lock | 2 +- 17 files changed, 183 insertions(+), 47 deletions(-) create mode 100644 CHANGELOG.d/0.89.0-analysis-run-live-write-clock.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 96b775ba..2d5cd852 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -486,8 +486,9 @@ unavailable, so that run is Failed rather than a fabricated score. The home list is clickable: `GET /api/analysis-runs/{id}` fills a labeled detail (cutoff, requested date, 12-character digest prefixes with full digests on hover, counts, status history) -without exposing a DSN or raw record. Opening a cutoff title warns -that the live body may have changed after the run. Status history is detail-only +without exposing a DSN or raw record. Opening a cutoff title still +shows the live body; titles rewritten after the run are marked +updated after cutoff. Status history is detail-only and uses lookup labels plus occurrence times; a failure event keeps its machine `failure_code` rather than an invented caption. Failed TEPP list rows add a next-action line (open the run, then connect the diff --git a/CHANGELOG.d/0.89.0-analysis-run-live-write-clock.md b/CHANGELOG.d/0.89.0-analysis-run-live-write-clock.md new file mode 100644 index 00000000..bb4bb980 --- /dev/null +++ b/CHANGELOG.d/0.89.0-analysis-run-live-write-clock.md @@ -0,0 +1,5 @@ +# 0.89.0 Analysis-run live write clock + +In-cutoff titles now say whether the live row was rewritten after the +run. Open Demo public post as the edited counter-example; Demo private +post still matches the January cutoff. Bodies stay live. diff --git a/CHANGELOG.md b/CHANGELOG.md index 42239b07..1ee7ecab 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.89.0] - 2026-08-17 + +### Added + +- Analysis-run detail now compares each in-cutoff title's live + `updated_at` with that run's knowledge cutoff. After `make seed`, + open the Demo Corp lineage run: Demo public post is marked + **Updated after cutoff**; Demo private post is not. Opening a + marked title still shows the live body -- cutoff body versioning + stays a later slice (ADR 0016). The list stays aggregates-only. + No TEPP theta is invented. + ## [0.88.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 5096a8ea..a855ef5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,8 +29,9 @@ mention TEPP. A failed period-report row rebuilds the report. A pending TEPP row does not claim a calibrated measurement. A pending 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). +Opening a cutoff title shows the live post. Titles marked updated +after cutoff were rewritten after the run; compare those bodies +before treating them as reconstructed evidence (ADR 0016). `POST /api/analysis-runs` records Pending on an authorized cutoff capture (ADR 0017). `POST /api/analysis-runs/{id}/start` reconstructs that frozen cutoff bag (ADR 0021) and does not invent a diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 4fe53d76..0a07f1e0 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -94,6 +94,22 @@ def _iso(value: Any) -> str: return value.isoformat() if hasattr(value, "isoformat") else str(value) +def _as_utc(value: datetime) -> datetime: + """Treat a naive clock as UTC so cutoff comparison stays timezone-aware.""" + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def live_write_after_cutoff(updated_at: datetime, knowledge_cutoff: datetime) -> bool: + """True when the live row was rewritten after the run's analysis clock. + + ``created_at <= knowledge_cutoff`` admits the title. ``updated_at`` is + the live write clock (ADR 0016). Equal times stay in-cutoff evidence. + """ + return _as_utc(updated_at) > _as_utc(knowledge_cutoff) + + async def _counts_by_run( conn: asyncpg.Connection, run_ids: list[str], @@ -373,15 +389,21 @@ async def fetch_visible_scope_posts( scope_key: str | None, affiliated_entity_ids: list[str], knowledge_cutoff: Any, -) -> list[dict[str, str]]: +) -> list[dict[str, Any]]: """ABAC-visible post titles known at the run cutoff -- never a hidden body. ``knowledge_cutoff`` is the analysis clock (W3C Time / ISO 8601-1:2019; ADR 0013/0016). A later live post must not appear inside an earlier run. + ``updated_at`` is compared separately so the operator can see which + in-cutoff titles were rewritten after that clock. The live body is + still not returned. """ + columns = ( + "post_id, post_title, visibility_code, corporate_entity_id, updated_at" + ) if scope_kind_code == "analysis_scope_corporate_entity" and corporate_entity_id: rows = await conn.fetch( - "select post_id, post_title, visibility_code, corporate_entity_id " + f"select {columns} " "from source_post where corporate_entity_id = $1 " "and created_at <= $2 " "order by created_at, post_title", @@ -390,7 +412,7 @@ async def fetch_visible_scope_posts( ) elif scope_kind_code == "analysis_scope_process_unit" and process_unit_id: rows = await conn.fetch( - "select post_id, post_title, visibility_code, corporate_entity_id " + f"select {columns} " "from source_post where process_unit_id = $1 " "and created_at <= $2 " "order by created_at, post_title", @@ -399,7 +421,7 @@ async def fetch_visible_scope_posts( ) elif scope_kind_code == "analysis_scope_thread_group" and scope_key: rows = await conn.fetch( - "select post_id, post_title, visibility_code, corporate_entity_id " + f"select {columns} " "from source_post where thread_group_key = $1 " "and created_at <= $2 " "order by created_at, post_title", @@ -408,7 +430,7 @@ async def fetch_visible_scope_posts( ) elif scope_kind_code == "analysis_scope_all_visible": rows = await conn.fetch( - "select post_id, post_title, visibility_code, corporate_entity_id " + f"select {columns} " "from source_post where created_at <= $1 " "order by created_at, post_title", knowledge_cutoff, @@ -416,12 +438,22 @@ async def fetch_visible_scope_posts( else: return [] affiliated = {str(entity_id) for entity_id in affiliated_entity_ids} - posts: list[dict[str, str]] = [] + posts: list[dict[str, Any]] = [] for row in rows: visible = row["visibility_code"] == "public" or str(row["corporate_entity_id"]) in affiliated if not visible: continue - posts.append({"post_id": str(row["post_id"]), "post_title": row["post_title"]}) + updated_at = row["updated_at"] + posts.append( + { + "post_id": str(row["post_id"]), + "post_title": row["post_title"], + "updated_at": _iso(updated_at), + "live_after_cutoff": live_write_after_cutoff( + updated_at, knowledge_cutoff + ), + } + ) return posts diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index ef147e99..bedef577 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -313,11 +313,21 @@ def _insert_post( visibility_code: str, body: str = "body", created_at: str = "2026-01-10T12:00:00Z", + updated_at: str | None = None, ) -> str: + written_at = updated_at if updated_at is not None else created_at cur.execute( - "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, created_at) " - "values (%s, %s, %s, %s, 'voc', %s, %s) returning post_id", - (account_id, corporate_entity_id, title, body, visibility_code, created_at), + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, created_at, updated_at) " + "values (%s, %s, %s, %s, 'voc', %s, %s, %s) returning post_id", + ( + account_id, + corporate_entity_id, + title, + body, + visibility_code, + created_at, + written_at, + ), ) return str(cur.fetchone()[0]) @@ -337,6 +347,14 @@ def _insert_post( "A follow-up written after the January 2026 run cutoff.", created_at="2026-01-20T12:00:00Z", ) + _insert_post( + "Edited own-corp private post", + own_corp_id, + "private", + "A January post rewritten after the run cutoff.", + created_at="2026-01-10T12:00:00Z", + updated_at="2026-01-13T09:00:00Z", + ) cur.execute( "insert into cataloged_person (person_name, person_side_code) values " @@ -502,8 +520,14 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( assert all("failure_code" not in event for event in history) titles = {post["post_title"] for post in body["visible_posts"]} assert "Own-corp private post" in titles + assert "Edited own-corp private post" in titles assert "Late own-corp private post" not in titles assert "Other-corp private post" not in titles + posts_by_title = {post["post_title"]: post for post in body["visible_posts"]} + assert posts_by_title["Own-corp private post"]["live_after_cutoff"] is False + assert posts_by_title["Edited own-corp private post"]["live_after_cutoff"] is True + assert posts_by_title["Edited own-corp private post"]["updated_at"].startswith("2026-01-13") + assert "post_body" not in posts_by_title["Edited own-corp private post"] assert "postgresql://" not in str(body) assert "visible_posts" not in visible diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index 08944337..274d34db 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -25,9 +25,11 @@ every scope branch (corporate entity, process unit, thread group, and all-visible). ABAC visibility is applied after that temporal gate. Click-through still opens the live post body -- post versioning is a later slice -- but the run list itself must not advertise a post the -run was not allowed to know. The detail must say that next action -plainly: compare the opened body with this cutoff before treating it -as reconstructed evidence. +run was not allowed to know. Detail compares the live `updated_at` +write clock with `knowledge_cutoff` and marks titles rewritten after +the run. The next action is specific: only those marked titles need a +cutoff comparison before treating the live body as reconstructed +evidence. Reproducibility digests on the same detail use a labeled group whose accessible name does not replace the visible prefixes (W3C Accessible @@ -44,11 +46,12 @@ run. - After `make seed`, the Demo Corp lineage run lists Demo public post and other in-cutoff Demo Corp titles. The later fixture account-review post (2026-02-10) does not appear. -- Open the run, read the live-body warning, then open a listed post - and compare it with the cutoff date. +- Open the run: Demo public post is marked updated after cutoff + (`updated_at` 2026-01-13). Demo private post is not. - Hover a digest prefix to read the full code or configuration digest when you need to match the API payload. -- Post-body versioning at the cutoff remains future work. +- Post-body versioning at the cutoff remains future work. The write + clock is a projection, not a stored cutoff body. - Thread-group *run list* visibility now uses the same cutoff (ADR 0018). A later public post cannot surface a previously hidden thread-group run. diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index 6d3427fa..69cf32d9 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -9,7 +9,7 @@ real-PostgreSQL contract tests. | Source | Product implication | Implemented evidence | |---|---|---| | W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. | -| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Opening a listed title warns that the live body may have changed after that cutoff. | +| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Detail compares live `updated_at` with that cutoff and marks titles rewritten after the run. | | W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. | | ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | | PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. | diff --git a/frontend/package.json b/frontend/package.json index 4c66c720..8f2244bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.88.0", + "version": "0.89.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index b0669325..5eea82f3 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -302,7 +302,20 @@ describe("App, authenticated", () => { count_value: 3, }, ], - visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + visible_posts: [ + { + post_id: "post-1", + post_title: "Public post", + updated_at: "2026-01-13T09:00:00Z", + live_after_cutoff: true, + }, + { + post_id: "post-2", + post_title: "Private post", + updated_at: "2026-01-10T12:00:00Z", + live_after_cutoff: false, + }, + ], code_revision_sha: "abcdef0123456789deadbeefcafebabe", configuration_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", @@ -1744,19 +1757,29 @@ describe("App, authenticated", () => { expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument(); expect( screen.getByText( - "Opening a title shows the live post. Compare it with cutoff 2026-01-12 before you treat the body as reconstructed evidence — it may have changed after this run.", + "Opening a title shows the live post. Titles marked updated after cutoff were rewritten after 2026-01-12. Compare those bodies with this run before you treat them as reconstructed evidence.", ), ).toBeInTheDocument(); expect( screen.getByRole("button", { - name: "Open live post (may have changed after cutoff): Public post", + name: "Open live post (updated after cutoff): Public post", }), ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Open live post: Private post", + }), + ).toBeInTheDocument(); + const cutoffPosts = screen.getByRole("list", { name: "Posts known at this run cutoff" }); + expect(cutoffPosts).toHaveTextContent("Updated after cutoff"); + expect(screen.getByRole("button", { name: "Open live post: Private post" }).closest("li")).not.toHaveTextContent( + "Updated after cutoff", + ); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); await userEvent.click( screen.getByRole("button", { - name: "Open live post (may have changed after cutoff): Public post", + name: "Open live post (updated after cutoff): Public post", }), ); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0296fb6b..f514b6ae 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1565,20 +1565,27 @@ function analysisRunDigestPrefix(digest: string): string { /** * Next action when a cutoff title opens the live post (ADR 0016). * - * Post-body versioning is a later slice. Until then the operator must - * compare the opened body with this run's cutoff instead of treating - * today's text as reconstructed evidence. + * Post-body versioning is a later slice. Titles marked + * `live_after_cutoff` were rewritten after this run; others still + * match the write clock the run knew. */ function analysisRunLivePostWarning(cutoffIso: string): string { const cutoffDate = cutoffIso.slice(0, 10); return ( - `Opening a title shows the live post. Compare it with cutoff ${cutoffDate} ` + - "before you treat the body as reconstructed evidence — it may have changed after this run." + `Opening a title shows the live post. Titles marked updated after cutoff ` + + `were rewritten after ${cutoffDate}. Compare those bodies with this run ` + + "before you treat them as reconstructed evidence." ); } -function analysisRunLivePostButtonLabel(postTitle: string): string { - return `Open live post (may have changed after cutoff): ${postTitle}`; +function analysisRunLivePostButtonLabel(post: { + post_title: string; + live_after_cutoff?: boolean; +}): string { + if (post.live_after_cutoff) { + return `Open live post (updated after cutoff): ${post.post_title}`; + } + return `Open live post: ${post.post_title}`; } function AnalysisRunReproducibilityDigests({ @@ -1811,11 +1818,14 @@ function AnalysisRunsPanel({
          • + {post.live_after_cutoff && ( + Updated after cutoff + )}
          • ))}
          diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0213c3fc..8c3298fa 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -544,6 +544,13 @@ export interface AnalysisRunReconstructedEdge { fused_score: number; } +export interface AnalysisRunVisiblePost { + post_id: string; + post_title: string; + updated_at?: string; + live_after_cutoff?: boolean; +} + export interface AnalysisRun { analysis_run_id: string; run_kind_code: AnalysisRunKindCode; @@ -557,7 +564,7 @@ export interface AnalysisRun { requested_at: string; source_counts: AnalysisRunCount[]; status_history?: AnalysisRunStatusEvent[]; - visible_posts?: { post_id: string; post_title: string }[]; + visible_posts?: AnalysisRunVisiblePost[]; reconstructed_edges?: AnalysisRunReconstructedEdge[]; reconstruction_result_sha256?: string; code_revision_sha?: string; diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 036dca1f..a158575e 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.88.0" +__version__ = "0.89.0" diff --git a/pyproject.toml b/pyproject.toml index 5a4aa12b..fecebee1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.88.0" +version = "0.89.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 8cb1f0ea..7ce3c31d 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -235,22 +235,27 @@ def seed( cur.execute("select post_id from source_post where post_title = 'Demo public post'") if cur.fetchone() is None: cur.execute( - "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code, created_at) " + "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code, created_at, updated_at) " "values (%s, %s, %s, 'Demo public post', " "'Ada West at Demo Corp followed up with Priya Nair at Northridge Grid about the delayed shipment.', " - "'voc', 'public', '2026-01-10T12:00:00Z')", + "'voc', 'public', '2026-01-10T12:00:00Z', '2026-01-13T09:00:00Z')", (account_ids["demo.analyst"], corporate_entity_id, process_units["DEMO-PU-A"]), ) cur.execute( - "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code, created_at) " - "values (%s, %s, %s, 'Demo private post', 'A synthetic private post scoped to Demo Corp accounts.', 'vom', 'private', '2026-01-10T12:00:00Z')", + "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code, created_at, updated_at) " + "values (%s, %s, %s, 'Demo private post', 'A synthetic private post scoped to Demo Corp accounts.', 'vom', 'private', '2026-01-10T12:00:00Z', '2026-01-10T12:00:00Z')", (account_ids["demo.admin"], corporate_entity_id, process_units["DEMO-PU-HQ"]), ) cur.execute( - "update source_post set created_at = '2026-01-10T12:00:00Z' " - "where post_title in ('Demo public post', 'Demo private post') " - "and created_at > '2026-01-12T12:00:00Z'" + "update source_post set created_at = '2026-01-10T12:00:00Z', " + "updated_at = '2026-01-13T09:00:00Z' " + "where post_title = 'Demo public post'" + ) + cur.execute( + "update source_post set created_at = '2026-01-10T12:00:00Z', " + "updated_at = '2026-01-10T12:00:00Z' " + "where post_title = 'Demo private post'" ) cur.execute("select post_id from source_post where post_title = 'Demo public post'") demo_public_post_id = cur.fetchone()[0] @@ -379,8 +384,8 @@ def insert_fixture_source_posts(cur, author_account_id, corporate_entity_id, pro "insert into source_post " "(author_account_id, corporate_entity_id, process_unit_id, " " post_title, post_body, voc_type_code, visibility_code, " - " thread_group_key, secondary_grouping_key, created_at) " - "values (%s, %s, %s, %s, %s, %s, 'public', %s, %s, %s) returning post_id", + " thread_group_key, secondary_grouping_key, created_at, updated_at) " + "values (%s, %s, %s, %s, %s, %s, 'public', %s, %s, %s, %s) returning post_id", ( author_account_id, corporate_entity_id, @@ -391,6 +396,7 @@ def insert_fixture_source_posts(cur, author_account_id, corporate_entity_id, pro rec.group_key, rec.secondary_key, occurred, + occurred, ), ) post_id = str(cur.fetchone()[0]) diff --git a/tests/test_analysis_run_create.py b/tests/test_analysis_run_create.py index 4e24a422..4b7ffcf5 100644 --- a/tests/test_analysis_run_create.py +++ b/tests/test_analysis_run_create.py @@ -5,6 +5,7 @@ from backend.app.analysis_run_ingestion import ( AnalysisRunCreateError, _resolve_corporate_entity_id, + live_write_after_cutoff, plan_analysis_run_capture, ) import pytest @@ -124,6 +125,17 @@ def test_empty_corpus_uses_the_cutoff_as_latest_available_time() -> None: assert capture.maximum_available_time == _CUTOFF +def test_live_write_clock_is_distinct_from_the_cutoff_admission_clock() -> None: + """An in-cutoff title can still have been rewritten after the run.""" + cutoff = _CUTOFF + assert live_write_after_cutoff(_EARLIER, cutoff) is False + assert live_write_after_cutoff(cutoff, cutoff) is False + assert live_write_after_cutoff( + datetime(2026, 1, 13, 9, 0, tzinfo=timezone.utc), cutoff + ) is True + assert live_write_after_cutoff(datetime(2026, 1, 13, 9, 0), cutoff) is True + + 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 6915a353..0367bbb8 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.87.0" +version = "0.89.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From ba5b8c147197f9be953e50329591fc13156243ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:11:14 +0900 Subject: [PATCH 119/161] feat: warn that cutoff-rewritten titles open the live body (v0.90.0) (#194) Opening a marked analysis-run title still shows the live post. The popup now says to compare it with this run instead of inventing a cutoff snapshot. --- ...0.90.0-analysis-run-cutoff-body-warning.md | 5 ++ CHANGELOG.md | 12 +++ ...016-analysis-run-knowledge-cutoff-posts.md | 16 ++-- frontend/package.json | 2 +- frontend/src/App.css | 9 +++ frontend/src/App.test.tsx | 43 +++++++++++ frontend/src/App.tsx | 74 ++++++++++++++++--- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 10 files changed, 146 insertions(+), 21 deletions(-) create mode 100644 CHANGELOG.d/0.90.0-analysis-run-cutoff-body-warning.md diff --git a/CHANGELOG.d/0.90.0-analysis-run-cutoff-body-warning.md b/CHANGELOG.d/0.90.0-analysis-run-cutoff-body-warning.md new file mode 100644 index 00000000..2ec2dfc3 --- /dev/null +++ b/CHANGELOG.d/0.90.0-analysis-run-cutoff-body-warning.md @@ -0,0 +1,5 @@ +# 0.90.0 Analysis-run cutoff body warning + +Opening a title marked updated after cutoff now says the popup body is +live and to compare it with this run. The popup does not invent the +earlier text. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee7ecab..f839f5e1 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.90.0] - 2026-08-17 + +### Added + +- Opening an analysis-run title marked **Updated after cutoff** now + shows a popup status that the body is live, not a cutoff snapshot + (ADR 0016). After `make seed`, open the Demo Corp lineage run and + click Demo public post: the warning appears above the live body + and tells you to compare it with this run. Demo private post and + the home post list do not. The popup does not invent the earlier + text. No TEPP theta is invented. + ## [0.89.0] - 2026-08-17 ### Added diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index 274d34db..0a550b86 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -27,9 +27,11 @@ Click-through still opens the live post body -- post versioning is a later slice -- but the run list itself must not advertise a post the run was not allowed to know. Detail compares the live `updated_at` write clock with `knowledge_cutoff` and marks titles rewritten after -the run. The next action is specific: only those marked titles need a -cutoff comparison before treating the live body as reconstructed -evidence. +the run. Opening a marked title shows a popup status that the body is +live and must be compared with this run; the earlier text is not +stored, so the popup does not invent it. The next action is specific: +only those marked titles need a cutoff comparison before treating the +live body as reconstructed evidence. Reproducibility digests on the same detail use a labeled group whose accessible name does not replace the visible prefixes (W3C Accessible @@ -47,11 +49,15 @@ run. and other in-cutoff Demo Corp titles. The later fixture account-review post (2026-02-10) does not appear. - Open the run: Demo public post is marked updated after cutoff - (`updated_at` 2026-01-13). Demo private post is not. + (`updated_at` 2026-01-13). Demo private post is not. Opening the + marked title shows a live-body status; the private title and the + home post list do not. - Hover a digest prefix to read the full code or configuration digest when you need to match the API payload. - Post-body versioning at the cutoff remains future work. The write - clock is a projection, not a stored cutoff body. + clock is a projection, not a stored cutoff body. The popup tells + the operator to compare the live body with this run instead of + inventing the earlier text. - Thread-group *run list* visibility now uses the same cutoff (ADR 0018). A later public post cannot surface a previously hidden thread-group run. diff --git a/frontend/package.json b/frontend/package.json index 8f2244bd..356c15b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.89.0", + "version": "0.90.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 5251e69f..135c251f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -88,6 +88,7 @@ :root { --lw-opacity-meta: 0.7; --lw-font-size-meta: 0.85rem; + --lw-color-warning: #b45309; } .post-meta { @@ -147,6 +148,14 @@ opacity: 0.7; } +.popup-live-body-warning { + margin: 0.75rem 0 1rem; + padding: 0.65rem 0.75rem; + border-left: 3px solid var(--lw-color-warning); + background: color-mix(in srgb, canvas 88%, var(--lw-color-warning) 12%); + font-size: var(--lw-font-size-meta); +} + .popup-section { margin-top: 1.5rem; padding-top: 1rem; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 5eea82f3..d031fa06 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1798,6 +1798,49 @@ describe("App, authenticated", () => { expect(teppHistory).not.toHaveTextContent("Succeeded"); }); + it("warns that a cutoff-rewritten title opens the live body, not a snapshot", async () => { + stubBackend(); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp", + }), + ); + await userEvent.click( + await screen.findByRole("button", { + name: "Open live post (updated after cutoff): Public post", + }), + ); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(screen.getByRole("status", { name: "Live body warning" })).toHaveTextContent( + "This is the live body, not a cutoff snapshot. Compare it with this 2026-01-12 run before you treat it as reconstructed evidence.", + ); + + const linkedPosts = screen.getAllByLabelText("Open post: Linked post"); + await userEvent.click(linkedPosts[linkedPosts.length - 1]); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click( + screen.getByRole("button", { + name: "Open live post: Private post", + }), + ); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click(screen.getByRole("button", { name: "View post: Public post" })); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); + }); + it("does not tell a failed lineage run to connect the measurement service", async () => { stubBackend({ failedLineageRun: true }); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f514b6ae..d2b429ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1162,6 +1162,7 @@ function PostDetailPopup({ accessToken, canExtract, graph, + liveBodyWarning, onClose, onSelectPost, }: { @@ -1169,6 +1170,7 @@ function PostDetailPopup({ accessToken: string; canExtract: boolean; graph: LineageGraph | null; + liveBodyWarning?: string | null; onClose: () => void; onSelectPost?: (postId: string) => void; }) { @@ -1243,6 +1245,11 @@ function PostDetailPopup({ {post.visibility_label ?? post.visibility_code} ·{" "} {new Date(post.created_at).toLocaleString()}

          + {liveBodyWarning ? ( +

          + {liveBodyWarning} +

          + ) : null}
          @@ -1562,12 +1569,18 @@ function analysisRunDigestPrefix(digest: string): string { return digest.slice(0, ANALYSIS_RUN_DIGEST_PREFIX_LENGTH); } +type SelectPostOptions = { + liveAfterCutoff?: boolean; + knowledgeCutoff?: string; +}; + /** * Next action when a cutoff title opens the live post (ADR 0016). * - * Post-body versioning is a later slice. Titles marked - * `live_after_cutoff` were rewritten after this run; others still - * match the write clock the run knew. + * Titles marked `live_after_cutoff` were rewritten after this run; + * others still match the write clock the run knew. The popup then + * states that the body is live. Cutoff body versioning stays later + * work -- we never invent the earlier text. */ function analysisRunLivePostWarning(cutoffIso: string): string { const cutoffDate = cutoffIso.slice(0, 10); @@ -1578,6 +1591,21 @@ function analysisRunLivePostWarning(cutoffIso: string): string { ); } +/** + * Popup next action when a marked cutoff title opens the live body. + * + * ADR 0016 does not store a historical snapshot. This copy must not + * invent the earlier text. + */ +function analysisRunOpenedBodyWarning(cutoffIso?: string | null): string { + const cutoffDate = cutoffIso?.slice(0, 10); + const when = cutoffDate ? ` this ${cutoffDate}` : " this"; + return ( + "This is the live body, not a cutoff snapshot. " + + `Compare it with${when} run before you treat it as reconstructed evidence.` + ); +} + function analysisRunLivePostButtonLabel(post: { post_title: string; live_after_cutoff?: boolean; @@ -1644,7 +1672,7 @@ function AnalysisRunsPanel({ onSelectPost, }: { accessToken: string; - onSelectPost: (postId: string) => void; + onSelectPost: (postId: string, options?: SelectPostOptions) => void; }) { const [runs, setRuns] = useState(null); const [selected, setSelected] = useState(null); @@ -1819,7 +1847,12 @@ function AnalysisRunsPanel({ @@ -2095,10 +2128,24 @@ function PostList({ accessToken }: { accessToken: string }) { const [graph, setGraph] = useState(null); const [error, setError] = useState(null); const [selectedPostId, setSelectedPostId] = useState(null); + const [openedAfterCutoff, setOpenedAfterCutoff] = useState(false); + const [openedCutoffIso, setOpenedCutoffIso] = useState(null); const [canRebuild, setCanRebuild] = useState(false); const [rebuilding, setRebuilding] = useState(false); const [rebuildError, setRebuildError] = useState(null); + function selectPost(postId: string, options?: SelectPostOptions) { + setSelectedPostId(postId); + setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); + setOpenedCutoffIso(options?.knowledgeCutoff ?? null); + } + + function closeSelectedPost() { + setSelectedPostId(null); + setOpenedAfterCutoff(false); + setOpenedCutoffIso(null); + } + useEffect(() => { fetchPosts(accessToken).then(setPosts).catch((err) => setError(String(err))); fetchLineageGraph(accessToken).then(setGraph).catch(() => setGraph({ nodes: [], edges: [] })); @@ -2126,9 +2173,9 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> - - - + + +

          Event Lineage

          @@ -2140,7 +2187,7 @@ function PostList({ accessToken }: { accessToken: string }) {
          {rebuildError &&

          {rebuildError}

          } {!graph &&

          Loading lineage graph...

          } - {graph && } + {graph && }
            {posts.map((post) => ( @@ -2148,7 +2195,7 @@ function PostList({ accessToken }: { accessToken: string }) { + )} + {analysisRunCanRequestTeppRetry(selected) && ( + )} {selected.reconstructed_edges && selected.reconstructed_edges.length > 0 && (
              {selected.reconstructed_edges.map((edge) => (
            • - {edge.child_post_title} follows {edge.parent_post_title} + + {" follows "} +
            • ))}
            diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8c3298fa..b8269e2b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -560,6 +560,7 @@ export interface AnalysisRun { scope_entity_name?: string; status_code: AnalysisRunStatusCode | null; status_label: string | null; + failure_code?: string; knowledge_cutoff: string; requested_at: string; source_counts: AnalysisRunCount[]; diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 937a2778..cc28322f 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.90.0" +__version__ = "0.92.0" diff --git a/pyproject.toml b/pyproject.toml index f9501e80..2a84bfa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.90.0" +version = "0.92.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index e7752b35..795586a7 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -1,16 +1,24 @@ """Start-reconstruction contracts: digest, freeze, 422/409, designed tree.""" +from datetime import datetime, timezone + +import pytest + from backend.app.analysis_run_ingestion import reconstructed_edge_is_visible from backend.app.analysis_run_start import ( AnalysisRunStartError, + configured_tepp_client, reconstruction_member_ids, reconstruction_result_digest, start_kind_rejection, start_write_conflict_error, + tepp_run_request, + tepp_submit_outcome, ) from backend.app.lineage_ingestion import records_from_source_posts from lineageweave.fixtures import sample_records from lineageweave.lineage_persistence import lineage_edge_specs +from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable def test_reconstruction_digest_is_stable_and_ignores_edge_order() -> None: @@ -85,18 +93,64 @@ def test_reconstructed_edge_hides_unaffiliated_private_titles() -> None: ) -def test_tepp_and_period_report_start_are_unprocessable() -> None: - """TEPP and period-report start stay 422 so this path cannot invent a score.""" - tepp = start_kind_rejection("analysis_run_tepp") - assert tepp is not None - assert tepp.status_code == 422 - assert "invent a measurement" in tepp.detail +def test_period_report_start_is_unprocessable_and_tepp_is_allowed() -> None: + """Period-report stays 422. TEPP start is allowed so tepp_client can run.""" report = start_kind_rejection("analysis_run_report") assert report is not None assert report.status_code == 422 assert "invent a measurement" in report.detail assert "period report" in report.detail assert start_kind_rejection("analysis_run_lineage") is None + assert start_kind_rejection("analysis_run_tepp") is None + + +def _tepp_request() -> AnalysisRunRequest: + return tepp_run_request( + idempotency_key="buyer-tepp-2026-w07", + snapshot_sha256="ab" * 32, + knowledge_cutoff=datetime(2026, 1, 12, 12, 0, tzinfo=timezone.utc), + corporate_entity_id="11111111-1111-1111-1111-111111111111", + ) + + +def test_tepp_run_request_is_the_published_wire_shape() -> None: + """Start builds TEPP's seven-field request from the frozen run.""" + request = _tepp_request() + payload = request.to_json() + assert payload["contract_version"] == 1 + assert payload["idempotency_key"] == "buyer-tepp-2026-w07" + assert payload["snapshot_id"] == "ab" * 32 + assert payload["knowledge_cutoff"] == "2026-01-12T12:00:00Z" + assert payload["model_contract_version"] == "tepp-analysis-run-v1" + assert payload["output_profile"] == "calibrated_event_measurement" + assert "theta" not in str(payload).casefold() + + +def test_tepp_submit_outcome_drops_a_missing_transport() -> None: + """A missing TEPP transport is Failed, never a fabricated score.""" + status, failure = tepp_submit_outcome(TeppClient(), _tepp_request()) + assert status == "analysis_status_failed" + assert failure == "tepp_not_available" + + +def test_tepp_submit_outcome_does_not_persist_an_empty_envelope() -> None: + """An accepted envelope is not a persistable measurement.""" + + class _Accepting(TeppClient): + def __init__(self) -> None: + super().__init__(transport=lambda _payload: {"status": "accepted"}) + + status, failure = tepp_submit_outcome(_Accepting(), _tepp_request()) + assert status == "analysis_status_failed" + assert failure == "tepp_result_not_persisted" + + +def test_configured_tepp_client_stays_unavailable_without_http() -> None: + """Empty or non-http URLs keep the default dropped channel.""" + assert isinstance(configured_tepp_client(""), TeppClient) + client = configured_tepp_client("file:///tmp/tepp.json") + with pytest.raises(TeppNotAvailable): + client.submit_analysis_run(_tepp_request()) def test_hidden_run_start_is_not_found() -> None: diff --git a/uv.lock b/uv.lock index 3f222bb0..31397893 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.90.0" +version = "0.92.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 254c83db2b7e6bec91c852073707fccfe833811a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:08:16 +0900 Subject: [PATCH 121/161] feat: seed the A-100 fork and persist start on a durable outbox (v0.94.0) (#196) * feat: seed the designed A-100 fork on the Demo Corp lineage run (v0.93.0) make seed already stamped Succeeded. The home detail now persists ThreadWeave's parent choices so open-after-seed shows the revised quote and delivery question. No TEPP theta is invented. * feat: persist start work on a durable outbox (v0.94.0) Start commits Running plus one outbox row, wakes Valkey, then delivers ThreadWeave or tepp_client. A crash no longer loses the work item. No TEPP theta is invented. --- ARCHITECTURE.md | 10 +- ...0.93.0-seed-analysis-run-reconstruction.md | 4 + CHANGELOG.d/0.94.0-analysis-run-outbox.md | 5 + CHANGELOG.md | 19 + CLAUDE.md | 11 +- backend/app/analysis_run_ingestion.py | 6 +- backend/app/analysis_run_outbox.py | 95 ++++ backend/app/analysis_run_start.py | 504 +++++++++++++----- backend/app/main.py | 37 +- backend/tests/test_api.py | 142 ++++- docker/postgres-init/Dockerfile | 1 + .../0013-normalized-analysis-run-registry.md | 10 +- .../0017-authorized-analysis-run-create.md | 2 +- .../adr/0021-authorized-analysis-run-start.md | 18 +- docs/adr/0022-authorized-tepp-start.md | 6 +- docs/adr/0023-analysis-run-outbox.md | 90 ++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 6 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 72 ++- frontend/src/App.tsx | 4 +- lineageweave/__init__.py | 2 +- migrations/0023_analysis_run_outbox.sql | 241 +++++++++ .../rollback/0023_analysis_run_outbox.sql | 38 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 135 +++++ tests/test_analysis_run_outbox.py | 105 ++++ ...test_analysis_run_reconstruction_schema.py | 1 + tests/test_analysis_run_registry_schema.py | 4 + tests/test_analysis_run_start.py | 2 +- .../test_seed_analysis_run_reconstruction.py | 38 ++ uv.lock | 2 +- 31 files changed, 1438 insertions(+), 176 deletions(-) create mode 100644 CHANGELOG.d/0.93.0-seed-analysis-run-reconstruction.md create mode 100644 CHANGELOG.d/0.94.0-analysis-run-outbox.md create mode 100644 backend/app/analysis_run_outbox.py create mode 100644 docs/adr/0023-analysis-run-outbox.md create mode 100644 migrations/0023_analysis_run_outbox.sql create mode 100644 migrations/rollback/0023_analysis_run_outbox.sql create mode 100644 tests/test_analysis_run_outbox.py create mode 100644 tests/test_seed_analysis_run_reconstruction.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4291ba01..1fcd327a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -474,9 +474,10 @@ 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/{id}/start` then runs ThreadWeave on that -frozen bag and persists run-scoped edges (ADR 0021), or submits TEPP -through `tepp_client` (ADR 0022). It does not invent a TEPP score. +`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 +`tepp_client` (ADR 0022). It does not invent a TEPP score. Request a lineage reconstruction from the home list, open the Pending row, then start reconstruction. A Pending TEPP row starts a measurement; a missing transport stays Failed / @@ -506,7 +507,8 @@ payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · Demo Corp" with "3 documents" and Pending / Running / Succeeded times, -and "TEPP measurement · Failed · Demo Corp" whose detail history ends +the designed A-100 fork as clickable reconstructed edges, and +"TEPP measurement · Failed · Demo Corp" whose detail history ends in Failed / `tepp_not_available`. A run-bearing registry is emptied only after an unrevoked `analysis_run_retention_grant` and `GRANT analysis_run_retention_admin`, diff --git a/CHANGELOG.d/0.93.0-seed-analysis-run-reconstruction.md b/CHANGELOG.d/0.93.0-seed-analysis-run-reconstruction.md new file mode 100644 index 00000000..9bd198d8 --- /dev/null +++ b/CHANGELOG.d/0.93.0-seed-analysis-run-reconstruction.md @@ -0,0 +1,4 @@ +# 0.93.0 Seed analysis-run reconstruction + +make seed persists the designed A-100 fork on the Demo Corp Succeeded +lineage run. Open that run and click the revised-quote child. diff --git a/CHANGELOG.d/0.94.0-analysis-run-outbox.md b/CHANGELOG.d/0.94.0-analysis-run-outbox.md new file mode 100644 index 00000000..49143656 --- /dev/null +++ b/CHANGELOG.d/0.94.0-analysis-run-outbox.md @@ -0,0 +1,5 @@ +# 0.94.0 Analysis-run start outbox + +Start commits Running plus a durable outbox row, wakes Valkey, then +delivers ThreadWeave or tepp_client. A crash no longer loses the work +item. No TEPP theta is invented. diff --git a/CHANGELOG.md b/CHANGELOG.md index b874d67d..a687e410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.94.0] - 2026-08-17 + +### Added + +- `POST /api/analysis-runs/{id}/start` now commits Running plus one + durable outbox row, wakes Valkey (`analysis-run-outbox`), then + delivers ThreadWeave or `tepp_client` (ADR 0023). A crash after + Start leaves the work item; refresh finishes it. Period-report + stays 422. No TEPP theta is invented. + +## [0.93.0] - 2026-08-17 + +### Added + +- `make seed` now persists the designed A-100 fork on the Demo Corp + Succeeded lineage run. Open that run: the revised quote and delivery + question follow the pricing follow-up and are buttons. Start is + unchanged. No TEPP theta is invented. + ## [0.92.0] - 2026-08-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index d6d38649..3f347a2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,8 +34,9 @@ after cutoff were rewritten after the run; compare those bodies before treating them as reconstructed evidence (ADR 0016). `POST /api/analysis-runs` records Pending on an authorized cutoff capture (ADR 0017). `POST /api/analysis-runs/{id}/start` -reconstructs that frozen cutoff bag (ADR 0021) 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 the parent-choice digest. +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 +the parent-choice digest. diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index fef422a5..2405b7b8 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -8,9 +8,9 @@ ``create_pending_analysis_run`` (ADR 0017) writes snapshot, counts, frozen membership, run, scope, and the first Pending event atomically. -``start_pending_analysis_run`` later reconstructs lineage (ADR 0021) -or submits TEPP through ``tepp_client`` (ADR 0022). Neither path -invents a TEPP 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 diff --git a/backend/app/analysis_run_outbox.py b/backend/app/analysis_run_outbox.py new file mode 100644 index 00000000..7abeb2b3 --- /dev/null +++ b/backend/app/analysis_run_outbox.py @@ -0,0 +1,95 @@ +"""Durable start-work outbox. PostgreSQL is truth; Valkey is the wake-up. + +ADR 0023. Start writes Running plus one immutable outbox row, then a +worker claims that row and runs ThreadWeave or ``tepp_client``. A crash +after enqueue leaves the work item; it does not invent a theta. +""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any + +import redis.asyncio as redis + +OUTBOX_STREAM_KEY = "analysis-run-outbox" +_CLAIMED = "analysis_outbox_claimed" +_DELIVERED = "analysis_outbox_delivered" + + +def outbox_request_digest( + *, + analysis_run_id: str, + work_kind_code: str, + snapshot_sha256: str, + knowledge_cutoff: datetime, +) -> str: + """SHA-256 of the frozen start request. Never hashes a post body.""" + cutoff = knowledge_cutoff + if cutoff.tzinfo is None: + cutoff = cutoff.replace(tzinfo=timezone.utc) + material = json.dumps( + { + "analysis_run_id": str(analysis_run_id), + "knowledge_cutoff": cutoff.astimezone(timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + "snapshot_sha256": snapshot_sha256, + "work_kind_code": work_kind_code, + }, + separators=(",", ":"), + sort_keys=True, + ) + return hashlib.sha256(material.encode()).hexdigest() + + +def outbox_stream_fields( + *, + analysis_run_id: str, + work_kind_code: str, + request_sha256: str, +) -> dict[str, str]: + """Valkey XADD fields for one start-work wake-up. No body, no theta.""" + return { + "analysis_run_id": str(analysis_run_id), + "request_sha256": request_sha256, + "work_kind_code": work_kind_code, + } + + +async def publish_outbox_event( + client: redis.Redis | None, + *, + analysis_run_id: str, + work_kind_code: str, + request_sha256: str, +) -> str | None: + """``XADD`` the wake-up. A missing Valkey leaves PostgreSQL durable.""" + if client is None: + return None + try: + entry_id = await client.xadd( + OUTBOX_STREAM_KEY, + outbox_stream_fields( + analysis_run_id=analysis_run_id, + work_kind_code=work_kind_code, + request_sha256=request_sha256, + ), + maxlen=1000, + approximate=True, + ) + except redis.RedisError: + return None + return str(entry_id) + + +def latest_outbox_delivery_is_delivered(status_code: str | None) -> bool: + """True when the newest delivery event already finished the work.""" + return status_code == _DELIVERED + + +def latest_outbox_delivery_is_claimed(status_code: str | None) -> bool: + """True when a worker already claimed the row and may retry.""" + return status_code == _CLAIMED diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 474bcd2e..37058d11 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -1,8 +1,10 @@ """Start a Pending lineage reconstruction or TEPP measurement. ADR 0021 reconstructs lineage. ADR 0022 starts TEPP through -``tepp_client`` only. Period-report stays another path. Neither start -invents a theta or a calibrated report score. +``tepp_client`` only. ADR 0023 enqueues that work on a durable outbox +so a crash after Running does not lose the item. Period-report stays +another path. Neither start invents a theta or a calibrated report +score. """ from __future__ import annotations @@ -19,6 +21,11 @@ AnalysisRunCreateError, fetch_visible_analysis_run, ) +from backend.app.analysis_run_outbox import ( + latest_outbox_delivery_is_claimed, + latest_outbox_delivery_is_delivered, + outbox_request_digest, +) from backend.app.lineage_ingestion import records_from_source_posts from lineageweave.http_client import HttpClientError, post_json from lineageweave.lineage_persistence import lineage_edge_specs @@ -253,23 +260,138 @@ async def _next_status_ordinal( return int(current_max) + 1 -async def start_pending_analysis_run( +async def _latest_outbox_delivery( + conn: asyncpg.Connection, + analysis_run_id: str, +) -> str | None: + """Newest outbox delivery status, or None when the row was never claimed.""" + return await conn.fetchval( + """ + select delivery_status_code + from analysis_run_outbox_delivery + where analysis_run_id = $1 + order by delivery_ordinal desc + limit 1 + """, + analysis_run_id, + ) + + +async def _next_outbox_delivery_ordinal( + conn: asyncpg.Connection, + analysis_run_id: str, +) -> int: + """Return the next contiguous outbox delivery ordinal for this run.""" + current_max = await conn.fetchval( + """ + select coalesce(max(delivery_ordinal), 0) + from analysis_run_outbox_delivery + where analysis_run_id = $1 + """, + analysis_run_id, + ) + return int(current_max) + 1 + + +async def _append_outbox_delivery( + conn: asyncpg.Connection, + analysis_run_id: str, + delivery_ordinal: int, + delivery_status_code: str, + occurred_at: datetime, + valkey_stream_entry_id: str | None = None, +) -> None: + """Append one claim or delivery event. Stream id is optional.""" + await conn.execute( + """ + insert into analysis_run_outbox_delivery + (analysis_run_id, delivery_ordinal, delivery_status_code, + occurred_at, valkey_stream_entry_id) + values ($1, $2, $3, $4, $5) + """, + analysis_run_id, + delivery_ordinal, + delivery_status_code, + occurred_at, + valkey_stream_entry_id, + ) + + +async def _visible_or_404( + conn: asyncpg.Connection, + analysis_run_id: str, + account_id: str, + affiliated_entity_ids: list[str], +) -> dict[str, Any]: + """Reload the authorized projection or hide the run.""" + started = await fetch_visible_analysis_run( + conn, + analysis_run_id, + account_id, + affiliated_entity_ids, + ) + if started is None: + raise AnalysisRunStartError(404, "This analysis run is not visible.") + return started + + +async def _attach_outbox_digest( + conn: asyncpg.Connection, + started: dict[str, Any], +) -> dict[str, Any]: + """Expose the wake-up digest to the start API, never to the client body.""" + digest = await conn.fetchval( + """ + select request_sha256 + from analysis_run_outbox + where analysis_run_id = $1 + """, + started["analysis_run_id"], + ) + if not digest: + return started + attached = dict(started) + attached["outbox_request_sha256"] = str(digest) + return attached + + +async def _lock_start_run( + conn: asyncpg.Connection, + analysis_run_id: str, +) -> asyncpg.Record: + """Lock the run row used by enqueue and delivery.""" + locked = await conn.fetchrow( + """ + select run.analysis_run_id, run.knowledge_cutoff, run.run_kind_code, + run.idempotency_key, run.analysis_source_snapshot_id, + snapshot.snapshot_sha256, scope.corporate_entity_id + from analysis_run run + join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id + join analysis_source_snapshot snapshot + on snapshot.analysis_source_snapshot_id = run.analysis_source_snapshot_id + where run.analysis_run_id = $1 + for update of run + """, + analysis_run_id, + ) + if locked is None: + raise AnalysisRunStartError(404, "This analysis run is not visible.") + return locked + + +async def enqueue_pending_analysis_run( conn: asyncpg.Connection, *, analysis_run_id: str, account_id: str, affiliated_entity_ids: list[str], - tepp_client: TeppClient | None = None, ) -> dict[str, Any]: - """Run ThreadWeave or submit TEPP on a visible Pending row. + """Append Running and one outbox row, or resume an undelivered item. Period-report is rejected so this path cannot invent a calibrated - score. TEPP goes through ``tepp_client`` and stays Failed when the - transport is missing or the envelope is not persistable. A Succeeded - retry returns the stored reconstruction (documented no-op replay). - A Running or concurrent write is 409. Hidden runs 404. The run row - is locked before Running so a double-click is 409 or a replay, - never a 500. + score. A Succeeded retry returns the stored reconstruction. A + Running row with an undelivered outbox is a crash resume. A Running + row without pending work is 409. Hidden runs 404. """ try: UUID(analysis_run_id) @@ -289,27 +411,8 @@ async def start_pending_analysis_run( raise kind_error if current["status_code"] == _SUCCEEDED: return current - if current["status_code"] != _PENDING: - raise AnalysisRunStartError( - 409, - "Open this run. Start is only for a Pending lineage reconstruction " - "or TEPP measurement.", - ) - locked = await conn.fetchrow( - """ - select run.analysis_run_id, run.knowledge_cutoff, run.run_kind_code, - run.idempotency_key, run.analysis_source_snapshot_id, - snapshot.snapshot_sha256, scope.corporate_entity_id - from analysis_run run - join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id - join analysis_source_snapshot snapshot - on snapshot.analysis_source_snapshot_id = run.analysis_source_snapshot_id - where run.analysis_run_id = $1 - for update of run - """, - analysis_run_id, - ) + locked = await _lock_start_run(conn, analysis_run_id) locked_status = await conn.fetchval( """ select status_code @@ -319,15 +422,35 @@ async def start_pending_analysis_run( analysis_run_id, ) if locked_status == _SUCCEEDED: - replayed = await fetch_visible_analysis_run( - conn, + return await _visible_or_404( + conn, analysis_run_id, account_id, affiliated_entity_ids + ) + if locked_status == _RUNNING: + latest = await _latest_outbox_delivery(conn, analysis_run_id) + if latest_outbox_delivery_is_delivered(latest): + raise AnalysisRunStartError( + 409, + "Open this run. Start is only for a Pending lineage reconstruction " + "or TEPP measurement.", + ) + has_outbox = await conn.fetchval( + """ + select 1 from analysis_run_outbox where analysis_run_id = $1 + """, analysis_run_id, - account_id, - affiliated_entity_ids, ) - if replayed is None: - raise AnalysisRunStartError(404, "This analysis run is not visible.") - return replayed + if has_outbox is None: + raise AnalysisRunStartError( + 409, + "Open this run. Start is only for a Pending lineage reconstruction " + "or TEPP measurement.", + ) + return await _attach_outbox_digest( + conn, + await _visible_or_404( + conn, analysis_run_id, account_id, affiliated_entity_ids + ), + ) if locked_status != _PENDING: raise AnalysisRunStartError( 409, @@ -335,123 +458,262 @@ async def start_pending_analysis_run( "or TEPP measurement.", ) - if locked["run_kind_code"] == _TEPP_KIND: - return await _start_tepp_measurement( - conn, - analysis_run_id=analysis_run_id, - account_id=account_id, - affiliated_entity_ids=affiliated_entity_ids, - locked=locked, - tepp_client=tepp_client or TeppClient(), - ) - now = datetime.now(timezone.utc) - running_ordinal = await _next_status_ordinal(conn, analysis_run_id) + digest = outbox_request_digest( + analysis_run_id=str(locked["analysis_run_id"]), + work_kind_code=str(locked["run_kind_code"]), + snapshot_sha256=str(locked["snapshot_sha256"]), + knowledge_cutoff=locked["knowledge_cutoff"], + ) try: - await _append_status(conn, analysis_run_id, running_ordinal, _RUNNING, now) - member_rows = await _snapshot_member_posts( + await _append_status( conn, - locked["analysis_source_snapshot_id"], + analysis_run_id, + await _next_status_ordinal(conn, analysis_run_id), + _RUNNING, + now, ) - if member_rows: - rows = member_rows - else: - rows = await _cutoff_source_posts( - conn, - corporate_entity_id=locked["corporate_entity_id"], - knowledge_cutoff=locked["knowledge_cutoff"], - affiliated_entity_ids=affiliated_entity_ids, - ) - edges = lineage_edge_specs(records_from_source_posts(rows)) - digest = reconstruction_result_digest(edges) - finished = datetime.now(timezone.utc) - if finished < now: - finished = now await conn.execute( """ - insert into analysis_run_reconstruction - (analysis_run_id, result_sha256, edge_count, reconstructed_at) + insert into analysis_run_outbox + (analysis_run_id, work_kind_code, request_sha256, enqueued_at) values ($1, $2, $3, $4) """, analysis_run_id, + locked["run_kind_code"], digest, - len(edges), - finished, - ) - for edge in edges: - await conn.execute( - """ - insert into analysis_run_lineage_edge - (analysis_run_id, child_post_id, parent_post_id, - fused_score, reconstructed_at) - values ($1, $2, $3, $4, $5) - """, - analysis_run_id, - edge.child_id, - edge.parent_id, - edge.fused_score, - finished, - ) - await _append_status( - conn, - analysis_run_id, - running_ordinal + 1, - _SUCCEEDED, - finished, + now, ) except asyncpg.UniqueViolationError as exc: raise start_write_conflict_error() from exc - started = await fetch_visible_analysis_run( + return await _attach_outbox_digest( conn, - analysis_run_id, - account_id, - affiliated_entity_ids, + await _visible_or_404( + conn, analysis_run_id, account_id, affiliated_entity_ids + ), ) - if started is None: - raise AnalysisRunStartError(404, "This analysis run is not visible.") - return started -async def _start_tepp_measurement( +async def deliver_queued_analysis_run( conn: asyncpg.Connection, *, analysis_run_id: str, account_id: str, affiliated_entity_ids: list[str], - locked: asyncpg.Record, - tepp_client: TeppClient, + tepp_client: TeppClient | None = None, + valkey_stream_entry_id: str | None = None, ) -> dict[str, Any]: - """Submit the frozen snapshot through ``tepp_client``. Never persist a theta.""" - now = datetime.now(timezone.utc) - running_ordinal = await _next_status_ordinal(conn, analysis_run_id) + """Claim the outbox row and finish ThreadWeave or TEPP. + + A delivered row replays the stored result. Missing work is 409. + TEPP stays Failed when the transport is missing or the envelope is + not persistable. No theta is invented. + """ try: - await _append_status(conn, analysis_run_id, running_ordinal, _RUNNING, now) - request = tepp_run_request( - idempotency_key=str(locked["idempotency_key"]), - snapshot_sha256=str(locked["snapshot_sha256"]), - knowledge_cutoff=locked["knowledge_cutoff"], - corporate_entity_id=str(locked["corporate_entity_id"]), + UUID(analysis_run_id) + except ValueError as exc: + raise AnalysisRunStartError(404, "This analysis run is not visible.") from exc + + current = await fetch_visible_analysis_run( + conn, + analysis_run_id, + account_id, + affiliated_entity_ids, + ) + if current is None: + raise AnalysisRunStartError(404, "This analysis run is not visible.") + if current["status_code"] == _SUCCEEDED: + return current + + outbox = await conn.fetchrow( + """ + select outbox.analysis_run_id, outbox.work_kind_code, + run.knowledge_cutoff, run.idempotency_key, + run.analysis_source_snapshot_id, snapshot.snapshot_sha256, + scope.corporate_entity_id + from analysis_run_outbox outbox + join analysis_run run on run.analysis_run_id = outbox.analysis_run_id + join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id + join analysis_source_snapshot snapshot + on snapshot.analysis_source_snapshot_id = run.analysis_source_snapshot_id + where outbox.analysis_run_id = $1 + for update of outbox + """, + analysis_run_id, + ) + if outbox is None: + raise AnalysisRunStartError( + 409, + "Open this run. Start is only for a Pending lineage reconstruction " + "or TEPP measurement.", + ) + latest = await _latest_outbox_delivery(conn, analysis_run_id) + if latest_outbox_delivery_is_delivered(latest): + return await _visible_or_404( + conn, analysis_run_id, account_id, affiliated_entity_ids ) - status_code, failure_code = tepp_submit_outcome(tepp_client, request) + now = datetime.now(timezone.utc) + try: + if not latest_outbox_delivery_is_claimed(latest): + await _append_outbox_delivery( + conn, + analysis_run_id, + await _next_outbox_delivery_ordinal(conn, analysis_run_id), + "analysis_outbox_claimed", + now, + valkey_stream_entry_id, + ) + if outbox["work_kind_code"] == _TEPP_KIND: + await _deliver_tepp_measurement( + conn, + analysis_run_id=analysis_run_id, + locked=outbox, + tepp_client=tepp_client or TeppClient(), + ) + else: + await _deliver_lineage_reconstruction( + conn, + analysis_run_id=analysis_run_id, + locked=outbox, + affiliated_entity_ids=affiliated_entity_ids, + ) finished = datetime.now(timezone.utc) if finished < now: finished = now - await _append_status( + await _append_outbox_delivery( conn, analysis_run_id, - running_ordinal + 1, - status_code, + await _next_outbox_delivery_ordinal(conn, analysis_run_id), + "analysis_outbox_delivered", finished, - failure_code, + valkey_stream_entry_id, ) except asyncpg.UniqueViolationError as exc: raise start_write_conflict_error() from exc - started = await fetch_visible_analysis_run( + return await _visible_or_404( + conn, analysis_run_id, account_id, affiliated_entity_ids + ) + + +async def start_pending_analysis_run( + conn: asyncpg.Connection, + *, + analysis_run_id: str, + account_id: str, + affiliated_entity_ids: list[str], + tepp_client: TeppClient | None = None, + valkey_stream_entry_id: str | None = None, +) -> dict[str, Any]: + """Enqueue then deliver on one connection. + + The HTTP start path commits the outbox before this delivery so a + crash leaves Running plus a durable work item. Callers that wrap + both steps in one transaction keep the older all-or-nothing + behavior. + """ + queued = await enqueue_pending_analysis_run( + conn, + analysis_run_id=analysis_run_id, + account_id=account_id, + affiliated_entity_ids=affiliated_entity_ids, + ) + if queued["status_code"] == _SUCCEEDED: + return queued + return await deliver_queued_analysis_run( + conn, + analysis_run_id=analysis_run_id, + account_id=account_id, + affiliated_entity_ids=affiliated_entity_ids, + tepp_client=tepp_client, + valkey_stream_entry_id=valkey_stream_entry_id, + ) + + +async def _deliver_lineage_reconstruction( + conn: asyncpg.Connection, + *, + analysis_run_id: str, + locked: asyncpg.Record, + affiliated_entity_ids: list[str], +) -> None: + """Persist ThreadWeave parent choices for the frozen bag.""" + now = datetime.now(timezone.utc) + member_rows = await _snapshot_member_posts( + conn, + locked["analysis_source_snapshot_id"], + ) + if member_rows: + rows = member_rows + else: + rows = await _cutoff_source_posts( + conn, + corporate_entity_id=locked["corporate_entity_id"], + knowledge_cutoff=locked["knowledge_cutoff"], + affiliated_entity_ids=affiliated_entity_ids, + ) + edges = lineage_edge_specs(records_from_source_posts(rows)) + digest = reconstruction_result_digest(edges) + finished = datetime.now(timezone.utc) + if finished < now: + finished = now + await conn.execute( + """ + insert into analysis_run_reconstruction + (analysis_run_id, result_sha256, edge_count, reconstructed_at) + values ($1, $2, $3, $4) + """, + analysis_run_id, + digest, + len(edges), + finished, + ) + for edge in edges: + await conn.execute( + """ + insert into analysis_run_lineage_edge + (analysis_run_id, child_post_id, parent_post_id, + fused_score, reconstructed_at) + values ($1, $2, $3, $4, $5) + """, + analysis_run_id, + edge.child_id, + edge.parent_id, + edge.fused_score, + finished, + ) + await _append_status( conn, analysis_run_id, - account_id, - affiliated_entity_ids, + await _next_status_ordinal(conn, analysis_run_id), + _SUCCEEDED, + finished, + ) + + +async def _deliver_tepp_measurement( + conn: asyncpg.Connection, + *, + analysis_run_id: str, + locked: asyncpg.Record, + tepp_client: TeppClient, +) -> None: + """Submit the frozen snapshot through ``tepp_client``. Never persist a theta.""" + now = datetime.now(timezone.utc) + request = tepp_run_request( + idempotency_key=str(locked["idempotency_key"]), + snapshot_sha256=str(locked["snapshot_sha256"]), + knowledge_cutoff=locked["knowledge_cutoff"], + corporate_entity_id=str(locked["corporate_entity_id"]), + ) + status_code, failure_code = tepp_submit_outcome(tepp_client, request) + finished = datetime.now(timezone.utc) + if finished < now: + finished = now + await _append_status( + conn, + analysis_run_id, + await _next_status_ordinal(conn, analysis_run_id), + status_code, + finished, + failure_code, ) - if started is None: - raise AnalysisRunStartError(404, "This analysis run is not visible.") - return started diff --git a/backend/app/main.py b/backend/app/main.py index da7067f4..55576ca1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -72,10 +72,12 @@ fetch_visible_analysis_run, fetch_visible_analysis_runs, ) +from backend.app.analysis_run_outbox import publish_outbox_event from backend.app.analysis_run_start import ( AnalysisRunStartError, configured_tepp_client, - start_pending_analysis_run, + deliver_queued_analysis_run, + enqueue_pending_analysis_run, ) from backend.app.activity_stream import ( create_valkey_client, @@ -1263,26 +1265,53 @@ async def start_analysis_run( analysis_run_id: str, account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: - """Start ThreadWeave or submit TEPP on a visible Pending run. + """Enqueue start work, then deliver ThreadWeave or TEPP. post_read is enough. Hidden runs 404. Period-report is 422 so this path cannot invent a calibrated score. TEPP goes through ``tepp_client`` and stays Failed when the transport is missing or the envelope is not persistable. A Succeeded lineage retry returns - the stored tree. A Running restart is 409. + the stored tree. A Running restart with an undelivered outbox + finishes that work. A Running restart without pending work is 409. + The outbox commits before reconstruct/TEPP so a crash leaves a + durable work item (ADR 0023). """ _require_post_read(account) settings = load_settings() async with pool.acquire() as conn: async with conn.transaction(): try: - started = await start_pending_analysis_run( + queued = await enqueue_pending_analysis_run( + conn, + analysis_run_id=analysis_run_id, + account_id=account.user_account_id, + affiliated_entity_ids=list(account.corporate_entity_ids), + ) + except AnalysisRunStartError as exc: + raise HTTPException(exc.status_code, exc.detail) from exc + if queued.get("status_code") == "analysis_status_succeeded": + return queued + request_digest = queued.pop("outbox_request_sha256", None) + stream_id = None + if request_digest: + stream_id = await publish_outbox_event( + valkey, + analysis_run_id=analysis_run_id, + work_kind_code=str(queued.get("run_kind_code") or ""), + request_sha256=request_digest, + ) + async with pool.acquire() as conn: + async with conn.transaction(): + try: + started = await deliver_queued_analysis_run( conn, analysis_run_id=analysis_run_id, account_id=account.user_account_id, affiliated_entity_ids=list(account.corporate_entity_ids), tepp_client=configured_tepp_client(settings.tepp_transport_url), + valkey_stream_entry_id=stream_id, ) except AnalysisRunStartError as exc: raise HTTPException(exc.status_code, exc.detail) from exc diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 9d5995bf..3d08b865 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -39,6 +39,9 @@ _SNAPSHOT_MEMBER_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0022_analysis_source_snapshot_member.sql" ) +_OUTBOX_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0023_analysis_run_outbox.sql" +) def _postgres_available() -> bool: @@ -125,6 +128,7 @@ def seeded_db(demo_analyst_token): cur.execute(_RETENTION_MIGRATION.read_text()) cur.execute(_RECONSTRUCTION_MIGRATION.read_text()) cur.execute(_SNAPSHOT_MEMBER_MIGRATION.read_text()) + cur.execute(_OUTBOX_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -669,6 +673,39 @@ def test_start_analysis_run_recovers_the_a100_fork( assert "Pricing renegotiation: revised quote sent" in children assert "Delivery schedule question raised" in children assert "theta" not in str(body).lower() + assert "outbox_request_sha256" not in body + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + """ + select outbox.work_kind_code, delivery.delivery_status_code + from analysis_run_outbox outbox + join analysis_run_outbox_delivery delivery + on delivery.analysis_run_id = outbox.analysis_run_id + where outbox.analysis_run_id = %s + order by delivery.delivery_ordinal desc + limit 1 + """, + (run_id,), + ) + outbox_row = cur.fetchone() + assert outbox_row == ("analysis_run_lineage", "analysis_outbox_delivered") + finally: + admin_conn.close() + valkey = redis.from_url(_VALKEY_URL, decode_responses=True) + try: + entries = valkey.xrevrange("analysis-run-outbox", count=50) + assert any( + fields.get("analysis_run_id") == run_id + and fields.get("work_kind_code") == "analysis_run_lineage" + and "theta" not in str(fields).casefold() + for _entry_id, fields in entries + ) + finally: + valkey.close() replay = client.post( f"/api/analysis-runs/{run_id}/start", @@ -823,6 +860,83 @@ def test_start_analysis_run_recovers_the_a100_fork( ) assert hidden.status_code == 404 + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'source-contract-v1', + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + ("3" * 64,), + ) + crash_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_lineage', 'buyer-start-outbox-resume', + %s, '2026-02-15T00:00:00Z', 'lineage-run-v1', %s, %s, + '2026-02-15T12:30:00Z') + returning analysis_run_id + """, + (crash_snapshot_id, requester_id, "2" * 64, "1" * 40), + ) + crash_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) + """, + (crash_run_id, seeded_db["own_corp_id"]), + ) + for ordinal, status, occurred in ( + (1, "analysis_status_pending", "2026-02-15T12:31:00Z"), + (2, "analysis_status_running", "2026-02-15T12:32:00Z"), + ): + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values (%s, %s, %s, %s) + """, + (crash_run_id, ordinal, status, occurred), + ) + cur.execute( + """ + insert into analysis_run_outbox + (analysis_run_id, work_kind_code, request_sha256, enqueued_at) + values (%s, 'analysis_run_lineage', %s, '2026-02-15T12:32:00Z') + """, + (crash_run_id, "a" * 64), + ) + finally: + admin_conn.close() + + resumed = client.post( + f"/api/analysis-runs/{crash_run_id}/start", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert resumed.status_code == 200, resumed.text + resumed_body = resumed.json() + assert resumed_body["status_label"] == "Succeeded" + assert "theta" not in str(resumed_body).lower() + children = { + edge["child_post_title"] + for edge in resumed_body["reconstructed_edges"] + if edge["parent_post_title"] == "Pricing renegotiation follow-up" + } + assert "Pricing renegotiation: revised quote sent" in children + def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> None: response = client.get("/api/me", headers={"Authorization": f"Bearer {demo_analyst_token}"}) @@ -836,7 +950,12 @@ def test_post_list_includes_public_and_own_corp_but_excludes_other_corp(client, response = client.get("/api/posts", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 titles = {post["post_title"] for post in response.json()} - assert titles == {"Public post", "Own-corp private post", "Late own-corp private post"} + assert titles == { + "Public post", + "Own-corp private post", + "Late own-corp private post", + "Edited own-corp private post", + } public = next(post for post in response.json() if post["post_title"] == "Public post") assert public["voc_type_label"] == "Voice of Customer" assert public["visibility_label"] == "Public" @@ -1934,14 +2053,19 @@ def test_thread_group_run_list_honors_knowledge_cutoff( """, (run_id,), ) - cur.execute( - """ - insert into analysis_run_status_event - (analysis_run_id, status_ordinal, status_code, occurred_at) - values (%s, 1, 'analysis_status_succeeded', '2026-01-12T12:33:00Z') - """, - (run_id,), - ) + for ordinal, status, occurred in ( + (1, "analysis_status_pending", "2026-01-12T12:31:00Z"), + (2, "analysis_status_running", "2026-01-12T12:32:00Z"), + (3, "analysis_status_succeeded", "2026-01-12T12:33:00Z"), + ): + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values (%s, %s, %s, %s) + """, + (run_id, ordinal, status, occurred), + ) finally: admin_conn.close() diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index 71e9fc73..2e016a60 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -28,6 +28,7 @@ COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-ro COPY migrations/0020_analysis_run_retention_purge.sql /docker-entrypoint-initdb.d/21-analysis-run-retention-purge.sql COPY migrations/0021_analysis_run_reconstruction.sql /docker-entrypoint-initdb.d/22-analysis-run-reconstruction.sql COPY migrations/0022_analysis_source_snapshot_member.sql /docker-entrypoint-initdb.d/23-analysis-source-snapshot-member.sql +COPY migrations/0023_analysis_run_outbox.sql /docker-entrypoint-initdb.d/24-analysis-run-outbox.sql # Official image already drops to this account at runtime; declare it so # the Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER postgres diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index 8184c9ff..96e1d891 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -160,7 +160,7 @@ runs. This migration does not claim that an API or UI exists. reviewed API. - **fast-mlsirm** owns Rust psychometric arithmetic and calibration interfaces. - **Valkey** remains the event queue. Durable registry truth remains in - PostgreSQL; a later outbox slice bridges the two. + PostgreSQL; the start outbox (ADR 0023) bridges the two. No component reads another service's private application tables. @@ -242,11 +242,13 @@ Acceptance requires: `POST /api/analysis-runs` now records that Pending write (ADR 0017). `POST /api/analysis-runs/{id}/start` now reconstructs a Pending lineage cutoff bag in-process from frozen snapshot membership - (ADR 0021). A durable outbox / Valkey worker remains a later slice. - Live TEPP start now submits through `tepp_client` (ADR 0022). + (ADR 0021). Start now commits a durable outbox row and wakes Valkey + before reconstruct / TEPP (ADR 0023). Live TEPP start submits + through `tepp_client` (ADR 0022). 2. Add RBAC/ABAC-protected run list/detail endpoints and the DB-grounded read-only administrator surface. -3. Add a normalized PostgreSQL outbox and Valkey delivery worker. +3. Add a normalized PostgreSQL outbox and Valkey delivery worker + (ADR 0023). 4. Add TEPP and contextual-orchestrator adapters only after their versioned contracts are present on reviewed main branches. Seed and `POST /api/analysis-runs/{id}/start` now record Failed TEPP through diff --git a/docs/adr/0017-authorized-analysis-run-create.md b/docs/adr/0017-authorized-analysis-run-create.md index ef6446a8..da2d5661 100644 --- a/docs/adr/0017-authorized-analysis-run-create.md +++ b/docs/adr/0017-authorized-analysis-run-create.md @@ -39,7 +39,7 @@ 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 remains a later slice. +outbox worker is ADR 0023. Do not stamp Succeeded or invent a theta from this write. ## References — APA 7th diff --git a/docs/adr/0021-authorized-analysis-run-start.md b/docs/adr/0021-authorized-analysis-run-start.md index 572bd884..5e0921a0 100644 --- a/docs/adr/0021-authorized-analysis-run-start.md +++ b/docs/adr/0021-authorized-analysis-run-start.md @@ -5,7 +5,7 @@ **Depends on:** ADR 0013 registry; ADR 0014 authorized read; ADR 0016 cutoff posts; ADR 0017 authorized create **Refs:** Issue #79 (Milestone 2 parent); ADR 0013 follow-up 3 (in-process -start; durable outbox remains later) +start; durable outbox is ADR 0023) ## Context @@ -15,10 +15,8 @@ Seed still owned the only Succeeded Demo Corp tree. A buyer cannot treat a request they cannot start as a product. ADR 0013 follow-up 3 asked for a PostgreSQL outbox and Valkey worker. -That durable delivery path is still later. This slice starts -reconstruction in the authorized request so the operator can see the -cutoff tree immediately. A crash after Running and before Succeeded -rolls the transaction back to Pending. +That durable delivery path is ADR 0023. This slice starts +reconstruction so the operator can see the cutoff tree immediately. Landed #145 occupies ADR 0020 / package 0.87.0 for granted retention purge. ADR 0019 binds R&R catalog identity. This decision is the next @@ -89,10 +87,12 @@ period-report rows do not show the button. ## Consequences Demo Analyst can request a run, start it, and confirm the designed A-100 -fork (revised quote and delivery question under the pricing follow-up) -without a seed-only Succeeded row. The durable outbox / Valkey worker -and live TEPP transport remain later slices. Do not stamp Succeeded -from a missing reconstruct library, and do not invent a theta. +fork (revised quote and delivery question under the pricing follow-up). +`make seed` also persists that fork on the Demo Corp Succeeded row so +open-after-seed is not empty. The durable outbox / Valkey worker is +ADR 0023. Live TEPP transport remains a later persistable-result +slice. Do not stamp Succeeded from a missing reconstruct library, and +do not invent a theta. ## References — APA 7th diff --git a/docs/adr/0022-authorized-tepp-start.md b/docs/adr/0022-authorized-tepp-start.md index 6949209f..bf6e54d5 100644 --- a/docs/adr/0022-authorized-tepp-start.md +++ b/docs/adr/0022-authorized-tepp-start.md @@ -70,9 +70,9 @@ sequenceDiagram end ``` -A durable outbox / Valkey worker remains a later slice. Start still -holds the request through the TEPP call so a crash rolls back to -Pending. +A durable outbox / Valkey worker is ADR 0023. Start commits Running +plus the outbox row before `tepp_client` so a crash leaves the work +item instead of rolling back to Pending. ## Consequences diff --git a/docs/adr/0023-analysis-run-outbox.md b/docs/adr/0023-analysis-run-outbox.md new file mode 100644 index 00000000..460b83a0 --- /dev/null +++ b/docs/adr/0023-analysis-run-outbox.md @@ -0,0 +1,90 @@ +# ADR 0023 — Durable start outbox and Valkey wake-up + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-17 +**Depends on:** ADR 0013 registry; ADR 0017 authorized create; ADR 0021 +authorized lineage start; ADR 0022 authorized TEPP start +**Refs:** Issue #79 (Milestone 2 parent); ADR 0013 follow-up 3 + +## Context + +ADR 0021 and ADR 0022 start a Pending lineage or TEPP run in the same +request transaction as reconstruct / `tepp_client`. That is honest: a +crash rolls back to Pending. It is not durable. A buyer who clicked +Start and then lost the process cannot tell whether work began. TEPP's +HTTP call also sits inside the registry write. + +ADR 0013 follow-up 3 asked for a normalized PostgreSQL outbox and a +Valkey delivery worker. The activity stream already uses Valkey as an +event queue, not a second database. The missing slice is one immutable +start-work row committed with Running, then a worker that claims that +row and finishes ThreadWeave or `tepp_client`. + +## Decision + +`POST /api/analysis-runs/{id}/start` splits into two authorized +transactions: + +1. lock the visible Pending run, append Running, insert one + `analysis_run_outbox` row whose digest hashes run id, kind, snapshot + digest, and cutoff — never a post body or a theta — then commit; +2. `XADD` the wake-up onto the Valkey stream `analysis-run-outbox` + (a missing Valkey does not roll back the outbox); +3. lock that outbox row, append `analysis_outbox_claimed`, run the same + reconstruct or `tepp_client` path as ADR 0021 / ADR 0022, append the + terminal status, then append `analysis_outbox_delivered`. + +A Succeeded retry still replays the stored digest. A Running restart +with an undelivered outbox is delivery, not a second start. A Running +row without pending work stays 409. Period-report stays 422. Failed +TEPP remains `tepp_not_available` / `tepp_result_not_persisted`. The +HTTP response still waits for delivery so the operator sees the A-100 +fork or the Failed TEPP row without polling. + +```mermaid +sequenceDiagram + participant Operator + participant API + participant Registry + participant Valkey + participant Worker + Operator->>API: POST /api/analysis-runs/{id}/start + API->>Registry: Running + outbox + Registry-->>API: committed work item + API->>Valkey: XADD analysis-run-outbox + API->>Worker: claim outbox + alt lineage + Worker->>Registry: reconstruction + Succeeded + else TEPP + Worker->>Registry: Failed tepp_not_available or tepp_result_not_persisted + end + API-->>Operator: 200 stored result +``` + +`make seed` writes a delivered outbox row on the Demo Corp lineage and +TEPP runs so open-after-seed matches the start path. Retention purge +deletes delivery, outbox, reconstruction, and snapshot membership +before the registry rows. + +## Consequences + +Start survives a crash after Running. Refreshing a queued run finishes +the same work item. Valkey is a wake-up, not a source of truth. Do not +invent a theta, and do not stamp Succeeded from a missing reconstruct +library. + +## References — APA 7th + +Hohpe, G., & Woolf, B. (2003). *Enterprise integration patterns: +Designing, building, and deploying messaging solutions*. +Addison-Wesley. + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. +World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C +Recommendation). https://www.w3.org/TR/prov-o/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index 951f72a1..aab8bb55 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -1,8 +1,8 @@ # Analysis-run registry standards and research traceability **Status:** Active PR evidence; not protected-main truth until merge. -**Scope:** Migrations 0018–0022, ADR 0013 / 0017 / 0020 / 0021, rollback, and -real-PostgreSQL contract tests. +**Scope:** Migrations 0018–0023, ADR 0013 / 0017 / 0020 / 0021 / 0022 / +0023, rollback, and real-PostgreSQL contract tests. ## Standards mapped to implementation @@ -16,7 +16,7 @@ real-PostgreSQL contract tests. | 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. | -| ThreadWeave tree assembly | Persist the same parent choices the library reconstructs on the cutoff bag. | `start_pending_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. | +| 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/frontend/package.json b/frontend/package.json index dc98dbd9..3b2e21ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.92.0", + "version": "0.94.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0b8b5b82..de5a6367 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -60,6 +60,7 @@ describe("App, authenticated", () => { searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; failedLineageRun?: boolean; + runningLineageRun?: boolean; failedReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; @@ -320,8 +321,16 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_succeeded", - status_label: "Succeeded", + status_code: options?.runningLineageRun + ? "analysis_status_running" + : options?.failedLineageRun + ? "analysis_status_failed" + : "analysis_status_succeeded", + status_label: options?.runningLineageRun + ? "Running" + : options?.failedLineageRun + ? "Failed" + : "Succeeded", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:30:00Z", source_counts: [ @@ -345,6 +354,23 @@ describe("App, authenticated", () => { live_after_cutoff: false, }, ], + reconstructed_edges: [ + { + parent_post_id: "post-2", + parent_post_title: "Pricing renegotiation follow-up", + child_post_id: "post-1", + child_post_title: "Pricing renegotiation: revised quote sent", + fused_score: 0.72, + }, + { + parent_post_id: "post-2", + parent_post_title: "Pricing renegotiation follow-up", + child_post_id: "post-delivery", + child_post_title: "Delivery schedule question raised", + fused_score: 0.68, + }, + ], + reconstruction_result_sha256: "aa".repeat(32), code_revision_sha: "abcdef0123456789deadbeefcafebabe", configuration_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", @@ -552,8 +578,14 @@ describe("App, authenticated", () => { scope_entity_name: "Demo Corp", status_code: options?.failedLineageRun ? "analysis_status_failed" - : "analysis_status_succeeded", - status_label: options?.failedLineageRun ? "Failed" : "Succeeded", + : options?.runningLineageRun + ? "analysis_status_running" + : "analysis_status_succeeded", + status_label: options?.failedLineageRun + ? "Failed" + : options?.runningLineageRun + ? "Running" + : "Succeeded", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:30:00Z", source_counts: [ @@ -1869,6 +1901,21 @@ describe("App, authenticated", () => { expect(history).toHaveTextContent("Running 2026-01-12 12:32"); expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument(); + const seededFork = screen.getByRole("list", { name: "Reconstructed lineage edges" }); + expect(seededFork).toHaveTextContent( + "Pricing renegotiation: revised quote sent follows Pricing renegotiation follow-up", + ); + expect(seededFork).toHaveTextContent( + "Delivery schedule question raised follows Pricing renegotiation follow-up", + ); + await userEvent.click( + screen.getByRole("button", { + name: "Open reconstructed child: Pricing renegotiation: revised quote sent", + }), + ); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + await userEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument(); expect( screen.getByText( "Opening a title shows the live post. Titles marked updated after cutoff were rewritten after 2026-01-12. Compare those bodies with this run before you treat them as reconstructed evidence.", @@ -1955,6 +2002,23 @@ describe("App, authenticated", () => { expect(screen.queryByRole("status", { name: "Live body warning" })).not.toBeInTheDocument(); }); + it("tells a running lineage run to refresh the durable outbox", async () => { + stubBackend({ runningLineageRun: true }); + render(); + + const lineageButton = await screen.findByRole("button", { + name: "Open analysis run: Lineage reconstruction · Running · Demo Corp", + }); + expect(lineageButton).toHaveTextContent( + "Refresh this run. Start already queued the work on the durable outbox.", + ); + await userEvent.click(lineageButton); + expect(screen.getByRole("button", { name: "Start reconstruction" })).toBeInTheDocument(); + expect( + screen.getAllByText("Refresh this run. Start already queued the work on the durable outbox."), + ).not.toHaveLength(0); + }); + it("does not tell a failed lineage run to connect the measurement service", async () => { stubBackend({ failedLineageRun: true }); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5b1a1eea..67767b41 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1491,6 +1491,7 @@ function analysisRunNextAction(run: AnalysisRun): string | null { } } case "analysis_status_running": + return "Refresh this run. Start already queued the work on the durable outbox."; case "analysis_status_succeeded": case "analysis_status_cancelled": case null: @@ -1664,7 +1665,8 @@ function AnalysisRunReproducibilityDigests({ function analysisRunCanStart(run: AnalysisRun): boolean { return ( (run.run_kind_code === "analysis_run_lineage" || run.run_kind_code === "analysis_run_tepp") && - run.status_code === "analysis_status_pending" + (run.status_code === "analysis_status_pending" || + run.status_code === "analysis_status_running") ); } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index cc28322f..eebd45bc 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.92.0" +__version__ = "0.94.0" diff --git a/migrations/0023_analysis_run_outbox.sql b/migrations/0023_analysis_run_outbox.sql new file mode 100644 index 00000000..2aaef8c2 --- /dev/null +++ b/migrations/0023_analysis_run_outbox.sql @@ -0,0 +1,241 @@ +-- Durable analysis-run start outbox (ADR 0023). +-- +-- Start appends Running and one immutable outbox row in the same +-- transaction. Reconstruct and TEPP then run from that row so a crash +-- no longer rolls the work item back to Pending. Valkey carries the +-- wake-up; PostgreSQL stays the source of truth. No post body or +-- fabricated psychometric score is stored. + +insert into common_lookup_value ( + lookup_category, lookup_code, lookup_label, display_order +) values + ('analysis_outbox_delivery', 'analysis_outbox_claimed', 'Claimed', 0), + ('analysis_outbox_delivery', 'analysis_outbox_delivered', 'Delivered', 1) +on conflict (lookup_code) do nothing; + +create table if not exists analysis_run_outbox ( + analysis_run_id uuid primary key + references analysis_run (analysis_run_id), + work_kind_code text not null, + request_sha256 text not null, + enqueued_at timestamptz not null, + recorded_at timestamptz not null default clock_timestamp(), + constraint analysis_run_outbox_kind_check + check (work_kind_code in ('analysis_run_lineage', 'analysis_run_tepp')), + constraint analysis_run_outbox_digest_check + check (request_sha256 ~ '^[0-9a-f]{64}$'), + constraint analysis_run_outbox_time_check + check (enqueued_at <= recorded_at) +); + +comment on table analysis_run_outbox is + 'One immutable start-work item per analysis run; never a post body ' + 'or a fabricated psychometric score.'; + +create table if not exists analysis_run_outbox_delivery ( + analysis_run_id uuid not null + references analysis_run_outbox (analysis_run_id), + delivery_ordinal integer not null, + delivery_status_code text not null, + occurred_at timestamptz not null, + valkey_stream_entry_id text, + primary key (analysis_run_id, delivery_ordinal), + constraint analysis_run_outbox_delivery_ordinal_check + check (delivery_ordinal >= 1), + constraint analysis_run_outbox_delivery_status_check + check ( + delivery_status_code in ( + 'analysis_outbox_claimed', + 'analysis_outbox_delivered' + ) + ), + constraint analysis_run_outbox_delivery_stream_check + check ( + valkey_stream_entry_id is null + or char_length(valkey_stream_entry_id) between 1 and 64 + ) +); + +comment on table analysis_run_outbox_delivery is + 'Append-only claim and delivery events for one start-work item.'; + +create or replace function reject_analysis_run_outbox_mutation() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_outbox_is_immutable'; +end +$$; + +comment on function reject_analysis_run_outbox_mutation() is + 'Rejects mutation of an enqueued start-work item.'; + +drop trigger if exists analysis_run_outbox_mutation_reject + on analysis_run_outbox; +create trigger analysis_run_outbox_mutation_reject +before update or delete on analysis_run_outbox +for each row execute function reject_analysis_run_outbox_mutation(); + +create or replace function reject_analysis_run_outbox_delivery_mutation() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_outbox_delivery_is_append_only'; +end +$$; + +comment on function reject_analysis_run_outbox_delivery_mutation() is + 'Rejects mutation of a start-work delivery event.'; + +drop trigger if exists analysis_run_outbox_delivery_mutation_reject + on analysis_run_outbox_delivery; +create trigger analysis_run_outbox_delivery_mutation_reject +before update or delete on analysis_run_outbox_delivery +for each row execute function reject_analysis_run_outbox_delivery_mutation(); + +create or replace function purge_analysis_run_registry(approval_token text) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + run_count bigint; + snapshot_count bigint; +begin + if not exists ( + select 1 + from analysis_run_retention_grant + where database_role_name = session_user + and revoked_at is null + ) then + raise exception 'analysis_run_retention_not_granted'; + end if; + + if not pg_has_role(session_user, 'analysis_run_retention_admin', 'member') then + raise exception 'analysis_run_retention_not_admin'; + end if; + + if approval_token is distinct from 'approved-retention-purge' then + raise exception 'analysis_run_retention_not_approved'; + end if; + + select count(*) into run_count from analysis_run; + select count(*) into snapshot_count from analysis_source_snapshot; + + alter table analysis_run_status_event + disable trigger analysis_run_status_event_delete_reject; + alter table analysis_run_scope + disable trigger analysis_run_scope_mutation_reject; + alter table analysis_run + disable trigger analysis_run_mutation_reject; + if to_regclass('public.analysis_run_outbox') is not null then + alter table analysis_run_outbox + disable trigger analysis_run_outbox_mutation_reject; + alter table analysis_run_outbox_delivery + disable trigger analysis_run_outbox_delivery_mutation_reject; + end if; + if to_regclass('public.analysis_run_reconstruction') is not null then + alter table analysis_run_reconstruction + disable trigger analysis_run_reconstruction_update_reject; + alter table analysis_run_lineage_edge + disable trigger analysis_run_lineage_edge_update_reject; + end if; + if to_regclass('public.analysis_source_snapshot_member') is not null then + alter table analysis_source_snapshot_member + disable trigger analysis_source_snapshot_member_update_reject; + end if; + + begin + if to_regclass('public.analysis_run_outbox_delivery') is not null then + delete from analysis_run_outbox_delivery; + delete from analysis_run_outbox; + end if; + if to_regclass('public.analysis_run_lineage_edge') is not null then + delete from analysis_run_lineage_edge; + delete from analysis_run_reconstruction; + end if; + if to_regclass('public.analysis_source_snapshot_member') is not null then + delete from analysis_source_snapshot_member; + end if; + delete from analysis_run_status_event; + delete from analysis_run_scope; + delete from analysis_run; + delete from analysis_source_count; + delete from analysis_source_snapshot; + exception + when others then + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + if to_regclass('public.analysis_run_outbox') is not null then + alter table analysis_run_outbox + enable trigger analysis_run_outbox_mutation_reject; + alter table analysis_run_outbox_delivery + enable trigger analysis_run_outbox_delivery_mutation_reject; + end if; + if to_regclass('public.analysis_run_reconstruction') is not null then + alter table analysis_run_reconstruction + enable trigger analysis_run_reconstruction_update_reject; + alter table analysis_run_lineage_edge + enable trigger analysis_run_lineage_edge_update_reject; + end if; + if to_regclass('public.analysis_source_snapshot_member') is not null then + alter table analysis_source_snapshot_member + enable trigger analysis_source_snapshot_member_update_reject; + end if; + raise; + end; + + alter table analysis_run + enable trigger analysis_run_mutation_reject; + alter table analysis_run_scope + enable trigger analysis_run_scope_mutation_reject; + alter table analysis_run_status_event + enable trigger analysis_run_status_event_delete_reject; + if to_regclass('public.analysis_run_outbox') is not null then + alter table analysis_run_outbox + enable trigger analysis_run_outbox_mutation_reject; + alter table analysis_run_outbox_delivery + enable trigger analysis_run_outbox_delivery_mutation_reject; + end if; + if to_regclass('public.analysis_run_reconstruction') is not null then + alter table analysis_run_reconstruction + enable trigger analysis_run_reconstruction_update_reject; + alter table analysis_run_lineage_edge + enable trigger analysis_run_lineage_edge_update_reject; + end if; + if to_regclass('public.analysis_source_snapshot_member') is not null then + alter table analysis_source_snapshot_member + enable trigger analysis_source_snapshot_member_update_reject; + end if; + + insert into analysis_run_retention_event ( + purged_run_count, + purged_snapshot_count, + approval_token_digest, + invoking_session_role, + invoking_current_role, + client_network_address + ) values ( + run_count, + snapshot_count, + encode(sha256(convert_to(approval_token, 'UTF8')), 'hex'), + session_user, + current_user, + inet_client_addr() + ); +end +$$; + +comment on function purge_analysis_run_registry(text) is + 'Empties immutable registry, reconstruction, membership, and outbox ' + 'relations after an unrevoked role grant, analysis_run_retention_admin ' + 'membership, and the documented approval token; records one ' + 'analysis_run_retention_event. Next action: export that event, delete ' + 'it, then roll back 0023, 0022, 0021, 0020, and 0018.'; diff --git a/migrations/rollback/0023_analysis_run_outbox.sql b/migrations/rollback/0023_analysis_run_outbox.sql new file mode 100644 index 00000000..c8aa1824 --- /dev/null +++ b/migrations/rollback/0023_analysis_run_outbox.sql @@ -0,0 +1,38 @@ +-- Fail-closed rollback for migration 0023. +-- +-- Outbox evidence must be exported or explicitly deleted under an +-- approved retention procedure before these objects can be removed. +-- The extended purge function stays; it already guards missing tables. + +begin; + +do $$ +declare + relation_name text; + relation_has_rows boolean; +begin + foreach relation_name in array array[ + 'analysis_run_outbox_delivery', + 'analysis_run_outbox' + ] loop + if to_regclass('public.' || relation_name) is not null then + execute format('select exists (select 1 from %I)', relation_name) + into relation_has_rows; + if relation_has_rows then + raise exception 'analysis_run_outbox_not_empty'; + end if; + end if; + end loop; +end +$$; + +drop trigger if exists analysis_run_outbox_delivery_mutation_reject + on analysis_run_outbox_delivery; +drop trigger if exists analysis_run_outbox_mutation_reject + on analysis_run_outbox; +drop function if exists reject_analysis_run_outbox_delivery_mutation(); +drop function if exists reject_analysis_run_outbox_mutation(); +drop table if exists analysis_run_outbox_delivery; +drop table if exists analysis_run_outbox; + +commit; diff --git a/pyproject.toml b/pyproject.toml index 2a84bfa9..d3682e50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.92.0" +version = "0.94.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 7ce3c31d..e215e3e4 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -124,6 +124,7 @@ def seed( cur.execute((migrations / "0020_analysis_run_retention_purge.sql").read_text()) cur.execute((migrations / "0021_analysis_run_reconstruction.sql").read_text()) cur.execute((migrations / "0022_analysis_source_snapshot_member.sql").read_text()) + cur.execute((migrations / "0023_analysis_run_outbox.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values @@ -1386,6 +1387,73 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - """, (run_id, ordinal, status, occurred), ) + _seed_demo_run_reconstruction(cur, run_id, corporate_entity_id) + _seed_demo_run_outbox(cur, run_id) + + +def seed_reconstruction_edges(rows: list[dict]) -> tuple: + """ThreadWeave parent choices and digest for seed and start. Never a theta.""" + from backend.app.analysis_run_start import reconstruction_result_digest + from backend.app.lineage_ingestion import records_from_source_posts + from lineageweave.lineage_persistence import lineage_edge_specs + + edges = lineage_edge_specs(records_from_source_posts(rows)) + return edges, reconstruction_result_digest(edges) + + +def _seed_demo_run_reconstruction(cur, analysis_run_id, corporate_entity_id) -> None: + """Persist the designed A-100 fork on the seeded Succeeded lineage run. + + Seed already stamps Succeeded. Without run-scoped edges the home + detail has cutoff titles and no fork. Reuses the same ThreadWeave + path start uses. Does not invent a TEPP score. + """ + from datetime import datetime, timezone + + cur.execute( + "select 1 from analysis_run_reconstruction where analysis_run_id = %s", + (analysis_run_id,), + ) + if cur.fetchone() is not None: + return + cur.execute( + """ + select post_id, post_title, created_at, visibility_code, + corporate_entity_id, process_unit_id, + thread_group_key, secondary_grouping_key + from source_post + where corporate_entity_id = %s + and created_at <= %s + order by created_at, post_title + """, + (corporate_entity_id, datetime(2026, 1, 12, 12, 0, tzinfo=timezone.utc)), + ) + columns = [desc[0] for desc in cur.description] + rows = [dict(zip(columns, row)) for row in cur.fetchall()] + if not rows: + return + edges, digest = seed_reconstruction_edges(rows) + finished = datetime(2026, 1, 12, 12, 33, tzinfo=timezone.utc) + cur.execute( + """ + insert into analysis_run_reconstruction + (analysis_run_id, result_sha256, edge_count, reconstructed_at) + values (%s, %s, %s, %s) + on conflict do nothing + """, + (analysis_run_id, digest, len(edges), finished), + ) + for edge in edges: + cur.execute( + """ + insert into analysis_run_lineage_edge + (analysis_run_id, child_post_id, parent_post_id, + fused_score, reconstructed_at) + values (%s, %s, %s, %s, %s) + on conflict do nothing + """, + (analysis_run_id, edge.child_id, edge.parent_id, edge.fused_score, finished), + ) def tepp_seed_request() -> AnalysisRunRequest: @@ -1484,6 +1552,73 @@ def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> No """, (run_id, ordinal, status, occurred, fail), ) + _seed_demo_run_outbox(cur, run_id) + + +def _seed_demo_run_outbox(cur, analysis_run_id) -> None: + """Record a delivered start-work item for the seeded run. + + Seed already stamped the terminal status. The outbox row proves the + same durable path start uses. No theta is stored. + """ + from datetime import datetime, timezone + + from backend.app.analysis_run_outbox import outbox_request_digest + + cur.execute( + "select 1 from analysis_run_outbox where analysis_run_id = %s", + (analysis_run_id,), + ) + if cur.fetchone() is not None: + return + cur.execute( + """ + select run.run_kind_code, run.knowledge_cutoff, snapshot.snapshot_sha256 + from analysis_run run + join analysis_source_snapshot snapshot + on snapshot.analysis_source_snapshot_id = run.analysis_source_snapshot_id + where run.analysis_run_id = %s + """, + (analysis_run_id,), + ) + row = cur.fetchone() + if row is None: + return + work_kind_code, knowledge_cutoff, snapshot_sha256 = row + digest = outbox_request_digest( + analysis_run_id=str(analysis_run_id), + work_kind_code=work_kind_code, + snapshot_sha256=snapshot_sha256, + knowledge_cutoff=knowledge_cutoff, + ) + if work_kind_code == "analysis_run_tepp": + claimed = datetime(2026, 1, 12, 12, 36, tzinfo=timezone.utc) + delivered = datetime(2026, 1, 12, 12, 37, tzinfo=timezone.utc) + else: + claimed = datetime(2026, 1, 12, 12, 32, tzinfo=timezone.utc) + delivered = datetime(2026, 1, 12, 12, 33, tzinfo=timezone.utc) + cur.execute( + """ + insert into analysis_run_outbox + (analysis_run_id, work_kind_code, request_sha256, enqueued_at) + values (%s, %s, %s, %s) + on conflict do nothing + """, + (analysis_run_id, work_kind_code, digest, claimed), + ) + for ordinal, status, occurred in ( + (1, "analysis_outbox_claimed", claimed), + (2, "analysis_outbox_delivered", delivered), + ): + cur.execute( + """ + insert into analysis_run_outbox_delivery + (analysis_run_id, delivery_ordinal, delivery_status_code, occurred_at) + values (%s, %s, %s, %s) + on conflict do nothing + """, + (analysis_run_id, ordinal, status, occurred), + ) def main() -> None: diff --git a/tests/test_analysis_run_outbox.py b/tests/test_analysis_run_outbox.py new file mode 100644 index 00000000..240006ba --- /dev/null +++ b/tests/test_analysis_run_outbox.py @@ -0,0 +1,105 @@ +"""Static and unit contracts for the durable analysis-run start outbox.""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from pathlib import Path + +from backend.app.analysis_run_outbox import ( + OUTBOX_STREAM_KEY, + latest_outbox_delivery_is_claimed, + latest_outbox_delivery_is_delivered, + outbox_request_digest, + outbox_stream_fields, +) +from backend.app.analysis_run_start import start_kind_rejection + +_ROOT = Path(__file__).resolve().parents[1] +_OUTBOX_MIGRATION = _ROOT / "migrations" / "0023_analysis_run_outbox.sql" +_OUTBOX_ROLLBACK = _ROOT / "migrations" / "rollback" / "0023_analysis_run_outbox.sql" +_POSTGRES_IMAGE = _ROOT / "docker" / "postgres-init" / "Dockerfile" + + +def test_outbox_migration_is_normalized_and_wired() -> None: + """Static contract: 3NF names, no payload JSON, Dockerfile copy, rollback.""" + migration = _OUTBOX_MIGRATION.read_text(encoding="utf-8") + rollback = _OUTBOX_ROLLBACK.read_text(encoding="utf-8") + dockerfile = _POSTGRES_IMAGE.read_text(encoding="utf-8") + created_tables = set( + re.findall(r"create table if not exists\s+([a-z0-9_]+)", migration, re.I) + ) + assert {"analysis_run_outbox", "analysis_run_outbox_delivery"} <= created_tables + assert "jsonb" not in migration.casefold() + assert "metadata_payload" not in migration + assert "theta" not in migration.casefold() + assert "0023_analysis_run_outbox.sql" in dockerfile + assert "analysis_run_outbox_not_empty" in rollback + assert "reject_analysis_run_outbox_mutation" in migration + assert "reject_analysis_run_outbox_delivery_mutation" in migration + assert "analysis_run_lineage_edge" in migration + assert "analysis_source_snapshot_member" in migration + object_patterns = ( + r"create table if not exists\s+([a-z0-9_]+)", + r"create or replace function\s+([a-z0-9_]+)", + r"create trigger\s+([a-z0-9_]+)", + ) + for pattern in object_patterns: + for object_name in re.findall(pattern, migration, re.I): + assert len(object_name.split("_")) >= 2, object_name + + +def test_outbox_request_digest_is_stable_and_ignores_bodies() -> None: + """The same frozen start hashes the same way and never includes a body.""" + cutoff = datetime(2026, 1, 12, 12, 0, tzinfo=timezone.utc) + digest = outbox_request_digest( + analysis_run_id="11111111-1111-1111-1111-111111111111", + work_kind_code="analysis_run_lineage", + snapshot_sha256="ab" * 32, + knowledge_cutoff=cutoff, + ) + again = outbox_request_digest( + analysis_run_id="11111111-1111-1111-1111-111111111111", + work_kind_code="analysis_run_lineage", + snapshot_sha256="ab" * 32, + knowledge_cutoff=datetime(2026, 1, 12, 12, 0), + ) + other = outbox_request_digest( + analysis_run_id="11111111-1111-1111-1111-111111111111", + work_kind_code="analysis_run_tepp", + snapshot_sha256="ab" * 32, + knowledge_cutoff=cutoff, + ) + assert digest == again + assert digest != other + assert "theta" not in digest + assert "Pricing renegotiation" not in digest + + +def test_outbox_stream_fields_are_the_wake_up_only() -> None: + """Valkey carries the run id and digest, never a measurement.""" + fields = outbox_stream_fields( + analysis_run_id="11111111-1111-1111-1111-111111111111", + work_kind_code="analysis_run_tepp", + request_sha256="cd" * 32, + ) + assert fields["work_kind_code"] == "analysis_run_tepp" + assert fields["request_sha256"] == "cd" * 32 + assert "theta" not in str(fields).casefold() + assert OUTBOX_STREAM_KEY == "analysis-run-outbox" + + +def test_outbox_delivery_helpers_distinguish_claimed_from_done() -> None: + """A claimed row is retryable. A delivered row is finished.""" + assert latest_outbox_delivery_is_claimed("analysis_outbox_claimed") + assert not latest_outbox_delivery_is_claimed("analysis_outbox_delivered") + assert latest_outbox_delivery_is_delivered("analysis_outbox_delivered") + assert not latest_outbox_delivery_is_delivered(None) + + +def test_period_report_never_enters_the_start_outbox() -> None: + """The outbox is for lineage and TEPP start, not a fabricated report.""" + report = start_kind_rejection("analysis_run_report") + assert report is not None + assert report.status_code == 422 + assert "invent a measurement" in report.detail diff --git a/tests/test_analysis_run_reconstruction_schema.py b/tests/test_analysis_run_reconstruction_schema.py index 8ca265f3..9362edc8 100644 --- a/tests/test_analysis_run_reconstruction_schema.py +++ b/tests/test_analysis_run_reconstruction_schema.py @@ -47,6 +47,7 @@ def test_reconstruction_migration_is_normalized_and_wired() -> None: assert "theta" not in migration.casefold() assert "0021_analysis_run_reconstruction.sql" in dockerfile assert "0022_analysis_source_snapshot_member.sql" in dockerfile + assert "0023_analysis_run_outbox.sql" in dockerfile assert "analysis_run_reconstruction_not_empty" in rollback assert "reject_analysis_run_reconstruction_update" in migration assert "reject_analysis_run_lineage_edge_update" in migration diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index 6041d309..18a1a91c 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -278,6 +278,7 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non assert "0020_analysis_run_retention_purge.sql" in dockerfile assert "0021_analysis_run_reconstruction.sql" in dockerfile assert "0022_analysis_source_snapshot_member.sql" in dockerfile + assert "0023_analysis_run_outbox.sql" in dockerfile seed = (_ROOT / "scripts" / "seed_demo_data.py").read_text(encoding="utf-8") assert seed.index("0019_role_catalog_identity.sql") < seed.index( "0020_analysis_run_retention_purge.sql" @@ -288,6 +289,9 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non assert seed.index("0021_analysis_run_reconstruction.sql") < seed.index( "0022_analysis_source_snapshot_member.sql" ) + assert seed.index("0022_analysis_source_snapshot_member.sql") < seed.index( + "0023_analysis_run_outbox.sql" + ) assert "analysis_run_registry_not_empty" in rollback retention = _RETENTION_MIGRATION.read_text(encoding="utf-8") retention_rollback = _RETENTION_ROLLBACK.read_text(encoding="utf-8") diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index 795586a7..e46aa4a0 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -161,7 +161,7 @@ def test_hidden_run_start_is_not_found() -> None: def test_running_restart_conflicts_and_succeeded_replay_is_documented() -> None: - """Running is 409. Succeeded replay is a documented no-op (200 in the API).""" + """Running without pending outbox is 409. Succeeded replay is a no-op.""" conflict = start_write_conflict_error() assert conflict.status_code == 409 assert "Refresh to see the stored tree" in conflict.detail diff --git a/tests/test_seed_analysis_run_reconstruction.py b/tests/test_seed_analysis_run_reconstruction.py new file mode 100644 index 00000000..7f73800e --- /dev/null +++ b/tests/test_seed_analysis_run_reconstruction.py @@ -0,0 +1,38 @@ +"""Seeded Succeeded lineage runs persist the designed A-100 fork.""" + +from datetime import datetime, timezone + +from lineageweave.fixtures import sample_records +from scripts.seed_demo_data import seed_reconstruction_edges + + +def _rows_from_fixtures() -> list[dict]: + cutoff = datetime(2026, 1, 12, 12, 0, tzinfo=timezone.utc) + rows: list[dict] = [] + for record in sample_records(): + occurred = record.occurred_at.replace(tzinfo=timezone.utc) + if occurred > cutoff: + continue + rows.append( + { + "post_id": record.record_id, + "post_title": record.label, + "created_at": occurred, + "visibility_code": "public", + "corporate_entity_id": "demo-corp", + "process_unit_id": "demo-pu", + "thread_group_key": record.group_key, + "secondary_grouping_key": record.secondary_key, + } + ) + return rows + + +def test_seed_reconstruction_recovers_the_a100_fork() -> None: + """Seed must persist the same parent choices start uses.""" + edges, digest = seed_reconstruction_edges(_rows_from_fixtures()) + children = {edge.child_id for edge in edges if edge.parent_id == "rec-002"} + assert children >= {"rec-003", "rec-004"} + assert "rec-006" not in {edge.child_id for edge in edges} + assert digest + assert "theta" not in digest diff --git a/uv.lock b/uv.lock index 31397893..499690db 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.92.0" +version = "0.94.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From a2e745a1346e11dc28359a56b9f66ad30b9e6e41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:09:46 +0900 Subject: [PATCH 122/161] feat: show outbox delivery and seed the period-report analysis run (v0.96.0) (#197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: show labeled outbox delivery on analysis-run detail (v0.95.0) Open the seeded Demo Corp lineage run to see Claimed then Delivered. Stream entry ids stay off the payload. No TEPP theta is invented. * feat: seed the Demo Corp period report on the analysis-run registry (v0.96.0) After make seed, open Period report · Succeeded · Demo Corp to confirm the cutoff posts. Mean θ stays on the period-report panel. Start stays 422. No TEPP theta is invented. --- ARCHITECTURE.md | 10 +- .../0.95.0-analysis-run-outbox-delivery.md | 4 + CHANGELOG.d/0.96.0-seed-period-report-run.md | 4 + CHANGELOG.md | 19 +++ CLAUDE.md | 8 +- backend/app/analysis_run_ingestion.py | 40 ++++++ .../0013-normalized-analysis-run-registry.md | 5 +- docs/adr/0014-authorized-analysis-run-read.md | 7 +- docs/adr/0023-analysis-run-outbox.md | 7 +- .../0024-seed-period-report-analysis-run.md | 67 ++++++++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 4 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 118 ++++++++++++++---- frontend/src/App.tsx | 9 ++ frontend/src/api.ts | 8 ++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- scripts/seed_demo_data.py | 84 ++++++++++++- tests/test_seed_report_run.py | 87 +++++++++++++ uv.lock | 2 +- 20 files changed, 445 insertions(+), 44 deletions(-) create mode 100644 CHANGELOG.d/0.95.0-analysis-run-outbox-delivery.md create mode 100644 CHANGELOG.d/0.96.0-seed-period-report-run.md create mode 100644 docs/adr/0024-seed-period-report-analysis-run.md create mode 100644 tests/test_seed_report_run.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1fcd327a..a7f6dfeb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -507,9 +507,13 @@ payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · Demo Corp" with "3 documents" and Pending / Running / Succeeded times, -the designed A-100 fork as clickable reconstructed edges, and -"TEPP measurement · Failed · Demo Corp" whose detail history ends -in Failed / `tepp_not_available`. +the designed A-100 fork as clickable reconstructed edges, Claimed +then Delivered outbox times, and "TEPP measurement · Failed · Demo +Corp" whose detail history ends in Failed / `tepp_not_available`. +Seed also records "Period report · Succeeded · Demo Corp" on that +same snapshot after the calibrated report tables are written +(ADR 0024). Open that row to confirm the cutoff posts; mean θ stays +on the period-report panel. Start stays 422. A run-bearing registry is emptied only after an unrevoked `analysis_run_retention_grant` and `GRANT analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')` diff --git a/CHANGELOG.d/0.95.0-analysis-run-outbox-delivery.md b/CHANGELOG.d/0.95.0-analysis-run-outbox-delivery.md new file mode 100644 index 00000000..a92a4149 --- /dev/null +++ b/CHANGELOG.d/0.95.0-analysis-run-outbox-delivery.md @@ -0,0 +1,4 @@ +# 0.95.0 Analysis-run outbox delivery history + +Open a seeded Succeeded lineage run to see Claimed then Delivered. +Stream ids stay off the payload. diff --git a/CHANGELOG.d/0.96.0-seed-period-report-run.md b/CHANGELOG.d/0.96.0-seed-period-report-run.md new file mode 100644 index 00000000..037723ba --- /dev/null +++ b/CHANGELOG.d/0.96.0-seed-period-report-run.md @@ -0,0 +1,4 @@ +# 0.96.0 Seeded period-report analysis run + +After `make seed`, open **Period report · Succeeded · Demo Corp**. +Mean θ stays on the period-report panel. No theta is copied. diff --git a/CHANGELOG.md b/CHANGELOG.md index a687e410..4134cc94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.96.0] - 2026-08-17 + +### Added + +- `make seed` now records **Period report · Succeeded · Demo Corp** on + the shared snapshot after the calibrated report tables are written + (ADR 0024). Open that row to confirm the cutoff posts. Mean θ stays + on the period-report panel. Start stays 422. No TEPP theta is + invented. + +## [0.95.0] - 2026-08-17 + +### Added + +- Analysis-run detail now lists labeled outbox delivery: Claimed then + Delivered (ADR 0023). After `make seed`, open the Demo Corp lineage + run to see those times. Stream entry ids stay off the payload. No + TEPP theta is invented. + ## [0.94.0] - 2026-08-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 3f347a2f..8f7bfda7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,10 +15,10 @@ back 0020 then 0018. The published phrase is not a secret. Do not retention grant to the application `DATABASE_URL` login. ADR 0019 is the R&R catalog-id bind, not this purge. -## Analysis-run seed (v0.85.0) +## Analysis-run seed (v0.96.0) -`make seed` writes a Demo Corp lineage run and a TEPP run on the same -snapshot (ADR 0013). The TEPP path goes through `tepp_client`. A missing +`make seed` writes a Demo Corp lineage run, a TEPP run, and a Succeeded +period-report run on the same snapshot (ADR 0013 / ADR 0024). The TEPP path goes through `tepp_client`. A missing transport or an unused accepted envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`). Do not invent a theta or a local psychometric substitute. The home list caption stays @@ -40,3 +40,5 @@ frozen cutoff bag (ADR 0021 / ADR 0023) or submits TEPP through envelope is Failed. Failed TEPP is terminal — request a new run, then start. Do not invent a theta. Hover the Result prefix to read the parent-choice digest. +After `make seed`, open **Period report · Succeeded · Demo Corp** +to confirm the cutoff posts; mean θ stays on the period-report panel. diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 2405b7b8..fe27c9e7 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -164,6 +164,45 @@ async def _status_history( return history +async def fetch_outbox_deliveries( + conn: asyncpg.Connection, + analysis_run_id: str, +) -> list[dict[str, Any]]: + """Labeled claim/delivery events for one already-visible run. + + Missing outbox tables mean migration 0023 is not applied. Stream + entry ids stay off the payload -- they are not buyer evidence. + """ + try: + rows = await conn.fetch( + """ + select delivery_ordinal, delivery_status_code, occurred_at + from analysis_run_outbox_delivery + where analysis_run_id = $1::uuid + order by delivery_ordinal + """, + analysis_run_id, + ) + except asyncpg.UndefinedTableError: + return [] + labels = await labels_for_codes( + conn, + [row["delivery_status_code"] for row in rows], + ) + return [ + { + "delivery_ordinal": int(row["delivery_ordinal"]), + "delivery_status_code": row["delivery_status_code"], + "delivery_status_label": labels.get( + row["delivery_status_code"], + row["delivery_status_code"], + ), + "occurred_at": _iso(row["occurred_at"]), + } + for row in rows + ] + + async def _serialize_runs( conn: asyncpg.Connection, rows: list[asyncpg.Record], @@ -256,6 +295,7 @@ async def fetch_visible_analysis_run( if row["failure_code"]: detail["failure_code"] = row["failure_code"] detail["status_history"] = await _status_history(conn, analysis_run_id) + detail["outbox_deliveries"] = await fetch_outbox_deliveries(conn, analysis_run_id) detail["visible_posts"] = await fetch_visible_scope_posts( conn, row["scope_kind_code"], diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index 96e1d891..15fd040d 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -255,7 +255,10 @@ Acceptance requires: `tepp_client` on the frozen snapshot; a persistable measurement remains a later slice. A missing or unused TEPP envelope must stay Failed (`tepp_not_available` / `tepp_result_not_persisted`) and must - not write a local psychometric substitute. + not write a local psychometric substitute. Seed also records a + Succeeded `analysis_run_report` on that snapshot after the + period-report tables are written (ADR 0024); the registry row does + not copy a theta. 5. Execute private actual-data analysis and store only signed aggregate and reproducibility manifests outside public source control. 6. Run browser E2E through real OIDC, product navigation, and evidence drill-down. diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 500c2bc2..a8d82acb 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -38,9 +38,10 @@ LineageWeave owns a fail-closed read projection of the #89 registry: ## Consequences -`make seed` writes one synthetic Demo Corp lineage run and one TEPP -run on the same snapshot so the existing React home page can show both -kinds without a second application. The TEPP run is Failed / +`make seed` writes one synthetic Demo Corp lineage run, one TEPP +run, and one Succeeded period-report run on the same snapshot so the +existing React home page can show all three kinds without a second +application (ADR 0024). The TEPP run is Failed / `tepp_not_available` when the default transport is missing -- the list keeps that machine code off the caption (this decision) and instead tells the operator to open the TEPP run, then connect the measurement diff --git a/docs/adr/0023-analysis-run-outbox.md b/docs/adr/0023-analysis-run-outbox.md index 460b83a0..e89630ef 100644 --- a/docs/adr/0023-analysis-run-outbox.md +++ b/docs/adr/0023-analysis-run-outbox.md @@ -69,9 +69,10 @@ before the registry rows. ## Consequences Start survives a crash after Running. Refreshing a queued run finishes -the same work item. Valkey is a wake-up, not a source of truth. Do not -invent a theta, and do not stamp Succeeded from a missing reconstruct -library. +the same work item. Valkey is a wake-up, not a source of truth. Detail +lists labeled Claimed / Delivered events; stream entry ids stay off +the payload. Do not invent a theta, and do not stamp Succeeded from a +missing reconstruct library. ## References — APA 7th diff --git a/docs/adr/0024-seed-period-report-analysis-run.md b/docs/adr/0024-seed-period-report-analysis-run.md new file mode 100644 index 00000000..9ee8f90b --- /dev/null +++ b/docs/adr/0024-seed-period-report-analysis-run.md @@ -0,0 +1,67 @@ +# ADR 0024 — Seed records the built period report on the shared snapshot + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-17 +**Depends on:** ADR 0013 normalized analysis-run registry; ADR 0003 +fast-mlsirm report integration; ADR 0014 authorized analysis-run read +**Refs:** Issue #79 (Milestone 2 parent). After `make seed`, lineage and +TEPP registry rows were visible on home Analysis runs, but the +calibrated period report lived only on the separate report panel. +ADR 0022 is authorized TEPP start. ADR 0023 is the durable start +outbox. This decision is the next free slot. + +## Context + +Seed already scores Demo Corp week-2/week-3 reports through +`fast-mlsirm` and persists them on the report tables. The analysis-run +registry already has `analysis_run_report`. Operators who opened +Analysis runs after `make seed` could retry a Failed TEPP transport or +inspect a Succeeded lineage tree, then had no registry row for the +report they could already see on the period-report panel. + +A fake Failed report row would contradict the built report. Copying +mean θ onto `analysis_run` would invent a psychometric field the +registry is not allowed to store (ADR 0013). Starting a period-report +run through the lineage/TEPP outbox would invent a calibrated score +on a path that is not allowed to (ADR 0021 / ADR 0022 / ADR 0023). + +## Decision + +- `_seed_demo_period_report` still builds the calibrated report first. +- `_seed_demo_report_run` then inserts `analysis_run_report` on the + same Demo Corp snapshot, scoped to the same corporate entity. +- The lifecycle is Pending → Running → Succeeded because the report + tables already hold the scored period. The run row stores only + registry digests and counts — never a theta, item bank, or provider + body. +- Home next-action copy for a Succeeded report stays empty. Failed + report fixtures still say rebuild the period report. +- `POST /api/analysis-runs` stays lineage-or-TEPP (ADR 0017). + `POST /api/analysis-runs/{id}/start` stays 422 for this kind. + This slice does not add a Request period-report button, does not + enqueue outbox work, and does not call TEPP. + +## Consequences + +After `make seed`, Demo Analyst opens Analysis runs and sees +**Period report · Succeeded · Demo Corp** next to the lineage and TEPP +rows. Opening it shows the cutoff posts. Mean θ remains on the +period-report panel. Re-seed is idempotent on +`demo-report-seed-2026-w02`. + +## References — APA 7th + +American Educational Research Association, American Psychological +Association, & National Council on Measurement in Education. (2014). +*Standards for educational and psychological testing*. American +Educational Research Association. + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. +World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C +Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index aab8bb55..11e4871f 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -2,14 +2,14 @@ **Status:** Active PR evidence; not protected-main truth until merge. **Scope:** Migrations 0018–0023, ADR 0013 / 0017 / 0020 / 0021 / 0022 / -0023, rollback, and real-PostgreSQL contract tests. +0023 / 0024, rollback, and real-PostgreSQL contract tests. ## Standards mapped to implementation | Source | Product implication | Implemented evidence | |---|---|---| | W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. | -| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Detail compares live `updated_at` with that cutoff and marks titles rewritten after the run. | +| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Detail compares live `updated_at` with that cutoff and marks titles rewritten after the run. Seed records the built period report as a later Succeeded run on that same snapshot (ADR 0024) without copying a theta onto the registry row. | | W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. | | ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | | PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. | diff --git a/frontend/package.json b/frontend/package.json index 3b2e21ac..85873e81 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.94.0", + "version": "0.96.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index de5a6367..49c9e786 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -62,6 +62,7 @@ describe("App, authenticated", () => { failedLineageRun?: boolean; runningLineageRun?: boolean; failedReportRun?: boolean; + succeededReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; postBody?: string; @@ -178,6 +179,7 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/analysis-runs/run-demo-report")) { + const reportSucceeded = Boolean(options?.succeededReportRun); return Promise.resolve( jsonResponse({ analysis_run_id: "run-demo-report", @@ -186,8 +188,8 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: reportSucceeded ? "analysis_status_succeeded" : "analysis_status_failed", + status_label: reportSucceeded ? "Succeeded" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:38:00Z", source_counts: [ @@ -197,22 +199,45 @@ describe("App, authenticated", () => { count_value: 3, }, ], - visible_posts: [], - status_history: [ - { - status_ordinal: 1, - status_code: "analysis_status_pending", - status_label: "Pending", - occurred_at: "2026-01-12T12:39:00Z", - }, - { - status_ordinal: 2, - status_code: "analysis_status_failed", - status_label: "Failed", - occurred_at: "2026-01-12T12:40:00Z", - failure_code: "period_report_rebuild_failed", - }, - ], + visible_posts: reportSucceeded + ? [{ post_id: "post-1", post_title: "Public post" }] + : [], + status_history: reportSucceeded + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:39:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:40:00Z", + }, + { + status_ordinal: 3, + status_code: "analysis_status_succeeded", + status_label: "Succeeded", + occurred_at: "2026-01-12T12:41:00Z", + }, + ] + : [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:39:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_failed", + status_label: "Failed", + occurred_at: "2026-01-12T12:40:00Z", + failure_code: "period_report_rebuild_failed", + }, + ], }), ); } @@ -371,6 +396,20 @@ describe("App, authenticated", () => { }, ], reconstruction_result_sha256: "aa".repeat(32), + outbox_deliveries: [ + { + delivery_ordinal: 1, + delivery_status_code: "analysis_outbox_claimed", + delivery_status_label: "Claimed", + occurred_at: "2026-01-12T12:32:00Z", + }, + { + delivery_ordinal: 2, + delivery_status_code: "analysis_outbox_delivered", + delivery_status_label: "Delivered", + occurred_at: "2026-01-12T12:33:00Z", + }, + ], code_revision_sha: "abcdef0123456789deadbeefcafebabe", configuration_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", @@ -626,7 +665,7 @@ describe("App, authenticated", () => { }, ], }, - ...(options?.failedReportRun + ...(options?.failedReportRun || options?.succeededReportRun ? [ { analysis_run_id: "run-demo-report", @@ -635,8 +674,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed" as const, - status_label: "Failed", + status_code: options?.succeededReportRun + ? ("analysis_status_succeeded" as const) + : ("analysis_status_failed" as const), + status_label: options?.succeededReportRun ? "Succeeded" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:38:00Z", source_counts: [ @@ -1869,6 +1910,8 @@ describe("App, authenticated", () => { expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); expect(list).not.toHaveTextContent("select "); + expect(list).not.toHaveTextContent("Claimed"); + expect(list).not.toHaveTextContent("Delivered"); expect(list).not.toHaveTextContent("Code abcdef012345"); expect(list).not.toHaveTextContent("Config 0123456789ab"); expect(list).not.toHaveTextContent("abcdef0123456789deadbeefcafebabe"); @@ -1900,6 +1943,11 @@ describe("App, authenticated", () => { expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); expect(history).toHaveTextContent("Running 2026-01-12 12:32"); expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); + const outbox = screen.getByRole("list", { name: "Analysis run outbox delivery" }); + expect(outbox).toHaveTextContent("Claimed 2026-01-12 12:32"); + expect(outbox).toHaveTextContent("Delivered 2026-01-12 12:33"); + expect(outbox).not.toHaveTextContent("valkey"); + expect(outbox).not.toHaveTextContent("stream"); expect(screen.getByRole("list", { name: "Posts known at this run cutoff" })).toBeInTheDocument(); const seededFork = screen.getByRole("list", { name: "Reconstructed lineage edges" }); expect(seededFork).toHaveTextContent( @@ -2040,6 +2088,34 @@ describe("App, authenticated", () => { expect(teppButton).not.toHaveTextContent("reconstruction"); }); + it("does not tell a succeeded period report to rebuild, reconstruct, or measure", async () => { + stubBackend({ succeededReportRun: true }); + render(); + + const reportButton = await screen.findByRole("button", { + name: "Open analysis run: Period report · Succeeded · Demo Corp", + }); + expect(reportButton).not.toHaveTextContent("rebuild the period report"); + expect(reportButton).not.toHaveTextContent("Reconstruction has not started yet"); + expect(reportButton).not.toHaveTextContent("The report has not been built yet"); + expect(reportButton).not.toHaveTextContent("measurement service"); + expect(reportButton).not.toHaveTextContent("θ"); + + await userEvent.click(reportButton); + expect( + await screen.findByRole("heading", { name: "Period report · Succeeded · Demo Corp" }), + ).toBeInTheDocument(); + expect(screen.queryByText(/rebuild the period report/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); + expect(screen.queryByText(/The report has not been built yet/)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Open live post: Public post", + }), + ).toBeInTheDocument(); + }); + it("does not tell a failed period report to connect the measurement service", async () => { stubBackend({ failedReportRun: true }); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67767b41..6bf05c4b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1922,6 +1922,15 @@ function AnalysisRunsPanel({ ))} )} + {selected.outbox_deliveries && selected.outbox_deliveries.length > 0 && ( +
              + {selected.outbox_deliveries.map((event) => ( +
            1. + {event.delivery_status_label} {event.occurred_at.slice(0, 16).replace("T", " ")} +
            2. + ))} +
            + )} {selected.visible_posts && selected.visible_posts.length > 0 ? ( <> {corpusHint &&

            {corpusHint}

            } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b8269e2b..f9fc5e4c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -536,6 +536,13 @@ export interface AnalysisRunStatusEvent { failure_code?: string; } +export interface AnalysisRunOutboxDelivery { + delivery_ordinal: number; + delivery_status_code: string; + delivery_status_label: string; + occurred_at: string; +} + export interface AnalysisRunReconstructedEdge { parent_post_id: string; parent_post_title: string; @@ -565,6 +572,7 @@ export interface AnalysisRun { requested_at: string; source_counts: AnalysisRunCount[]; status_history?: AnalysisRunStatusEvent[]; + outbox_deliveries?: AnalysisRunOutboxDelivery[]; visible_posts?: AnalysisRunVisiblePost[]; reconstructed_edges?: AnalysisRunReconstructedEdge[]; reconstruction_result_sha256?: string; diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index eebd45bc..6d722c46 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.94.0" +__version__ = "0.96.0" diff --git a/pyproject.toml b/pyproject.toml index d3682e50..30f4c165 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.94.0" +version = "0.96.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index e215e3e4..7d8a2c94 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -39,11 +39,12 @@ DEFAULT_KEYCLOAK_ADMIN_USER = os.environ.get("KEYCLOAK_ADMIN", "admin") DEFAULT_VALKEY_URL = "redis://localhost:16379/0" -# ADR 0013: one Demo Corp capture, many runs (lineage + TEPP). +# ADR 0013: one Demo Corp capture, many runs (lineage + TEPP + report). DEMO_SOURCE_SNAPSHOT_MATERIAL = b"lineageweave-synthetic-demo-snapshot-v1" DEMO_SOURCE_CONTRACT_VERSION = "demo-source-contract-v1" DEMO_LINEAGE_IDEMPOTENCY_KEY = "demo-lineage-seed-2026-w02" DEMO_TEPP_IDEMPOTENCY_KEY = "demo-tepp-seed-2026-w02" +DEMO_REPORT_IDEMPOTENCY_KEY = "demo-report-seed-2026-w02" # (post_title, ticket_title, due_date) -- Event Lineage fixtures a report # member click opens. Activity seed uses the same titles so Valkey matches. @@ -356,6 +357,11 @@ def seed( account_ids["demo.analyst"], corporate_entity_id, ) + _seed_demo_report_run( + cur, + account_ids["demo.analyst"], + corporate_entity_id, + ) conn.commit() finally: @@ -1235,9 +1241,9 @@ def demo_source_snapshot_sha256() -> str: def _ensure_demo_source_snapshot(cur): """Return the shared Demo Corp capture, inserting it on first seed. - Lineage and TEPP runs share this snapshot (ADR 0013: one capture, - many runs). The digest is a hash of a fixed demo contract string -- - never a source row or DSN. + Lineage, TEPP, and period-report runs share this snapshot + (ADR 0013: one capture, many runs). The digest is a hash of a + fixed demo contract string -- never a source row or DSN. """ digest = demo_source_snapshot_sha256() cur.execute( @@ -1555,6 +1561,76 @@ def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> No _seed_demo_run_outbox(cur, run_id) +def _seed_demo_report_run(cur, requested_by_account_id, corporate_entity_id) -> None: + """Record the already-built Demo Corp period report on the shared snapshot. + + ``_seed_demo_period_report`` persists calibrated report tables first. + This registry row is Succeeded because that write already happened. + It does not copy a theta onto ``analysis_run``, does not invent a + local psychometric substitute, and does not enqueue start outbox + work (ADR 0024). Start stays 422. + """ + snapshot_id = _ensure_demo_source_snapshot(cur) + _ensure_demo_source_counts(cur, snapshot_id) + _ensure_demo_source_snapshot_members(cur, snapshot_id, corporate_entity_id) + cur.execute( + """ + select analysis_run_id from analysis_run + where requested_by_account_id = %s + and idempotency_key = %s + """, + (requested_by_account_id, DEMO_REPORT_IDEMPOTENCY_KEY), + ) + run_row = cur.fetchone() + if run_row is None: + cur.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, 'analysis_run_report', %s, + %s, '2026-01-12T12:00:00Z', 'report-run-v1', %s, %s, + '2026-01-12T12:38:00Z') + returning analysis_run_id + """, + ( + snapshot_id, + DEMO_REPORT_IDEMPOTENCY_KEY, + requested_by_account_id, + "f" * 64, + "a" * 40, + ), + ) + run_id = cur.fetchone()[0] + else: + run_id = run_row[0] + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, corporate_entity_id) + values (%s, 'analysis_scope_corporate_entity', %s) + on conflict (analysis_run_id) do nothing + """, + (run_id, corporate_entity_id), + ) + for ordinal, status, occurred in ( + (1, "analysis_status_pending", "2026-01-12T12:39:00Z"), + (2, "analysis_status_running", "2026-01-12T12:40:00Z"), + (3, "analysis_status_succeeded", "2026-01-12T12:41:00Z"), + ): + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values (%s, %s, %s, %s) + on conflict do nothing + """, + (run_id, ordinal, status, occurred), + ) + + def _seed_demo_run_outbox(cur, analysis_run_id) -> None: """Record a delivered start-work item for the seeded run. diff --git a/tests/test_seed_report_run.py b/tests/test_seed_report_run.py new file mode 100644 index 00000000..f1884055 --- /dev/null +++ b/tests/test_seed_report_run.py @@ -0,0 +1,87 @@ +"""Seeded period-report analysis runs record the built report, never a theta.""" + +import inspect + +from scripts.seed_demo_data import ( + DEMO_REPORT_IDEMPOTENCY_KEY, + _seed_demo_report_run, + seed, +) + + +class _ReportSeedCursor: + """Drive ``_seed_demo_report_run`` without a live database.""" + + def __init__(self) -> None: + self.statements: list[str] = [] + self.params: list[object] = [] + + def execute(self, sql: str, params=None) -> None: + self.statements.append(" ".join(sql.split())) + self.params.append(params) + + def fetchone(self): + last = self.statements[-1] + if last.lstrip().startswith("select") and "from analysis_source_snapshot" in last: + return None + if "insert into analysis_source_snapshot" in last: + return ("snapshot-demo",) + if last.lstrip().startswith("select") and "from analysis_source_count" in last: + return None + if last.lstrip().startswith("select") and "from analysis_run" in last: + return None + if "insert into analysis_run" in last: + return ("run-demo-report",) + return None + + +def test_seed_calls_report_run_after_period_report_tables() -> None: + """``seed()`` must persist scored tables before the Succeeded registry row.""" + source = inspect.getsource(seed) + period_at = source.index("_seed_demo_period_report(") + report_at = source.index("_seed_demo_report_run(") + assert period_at < report_at + assert "theta" not in source[period_at:report_at].lower() + assert "θ" not in source[period_at:report_at] + + +def test_seed_demo_report_run_inserts_succeeded_report_without_a_theta() -> None: + cursor = _ReportSeedCursor() + _seed_demo_report_run(cursor, "account-1", "corp-1") + run_inserts = [ + (sql, params) + for sql, params in zip(cursor.statements, cursor.params, strict=True) + if "insert into analysis_run" in sql + ] + assert run_inserts, "seed must insert the period-report analysis_run row" + sql, params = run_inserts[0] + assert "analysis_run_report" in sql + assert params is not None + assert DEMO_REPORT_IDEMPOTENCY_KEY in params + assert "report-run-v1" in sql + assert not any( + isinstance(value, str) and ("theta" in value.lower() or "θ" in value) + for value in params + ) + status_params = [ + params + for sql, params in zip(cursor.statements, cursor.params, strict=True) + if "insert into analysis_run_status_event" in sql + ] + assert any( + event_params is not None and "analysis_status_succeeded" in event_params + for event_params in status_params + ) + assert not any( + event_params is not None and "analysis_status_failed" in event_params + for event_params in status_params + ) + assert not any( + event_params is not None + and any( + isinstance(value, str) and "theta" in value.lower() + for value in event_params + ) + for event_params in status_params + ) + assert not any("analysis_run_outbox" in sql for sql in cursor.statements) diff --git a/uv.lock b/uv.lock index 499690db..5825844b 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.94.0" +version = "0.96.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 54c4312ae3552a35b49f027d803eda36e8e21e73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:10:13 +0900 Subject: [PATCH 123/161] feat: open the scored week from a period-report analysis run (v0.97.0) (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: open the scored week from a period-report analysis run (v0.97.0) After make seed, open Period report · Succeeded · Demo Corp and click Open period report 2026-W02. Mean θ stays on the report panel. No TEPP theta is invented. * fix(ui): focus the opened week and keep failed report rows closed Open period report 2026-W02 now focuses the report period field. Failed rows stay without the button. Seed still writes scope_key 2026-W02 and never a theta. --- .../0.97.0-analysis-run-open-period-report.md | 5 ++ CHANGELOG.md | 10 +++ CLAUDE.md | 5 +- backend/app/analysis_run_ingestion.py | 2 + .../0024-seed-period-report-analysis-run.md | 6 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 63 ++++++++++-------- frontend/src/App.tsx | 64 +++++++++++++++++-- frontend/src/api.ts | 1 + lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- scripts/seed_demo_data.py | 9 +-- tests/test_seed_report_run.py | 17 +++++ uv.lock | 2 +- 14 files changed, 147 insertions(+), 43 deletions(-) create mode 100644 CHANGELOG.d/0.97.0-analysis-run-open-period-report.md diff --git a/CHANGELOG.d/0.97.0-analysis-run-open-period-report.md b/CHANGELOG.d/0.97.0-analysis-run-open-period-report.md new file mode 100644 index 00000000..e42dc8be --- /dev/null +++ b/CHANGELOG.d/0.97.0-analysis-run-open-period-report.md @@ -0,0 +1,5 @@ +# 0.97.0 Open the seeded period report from its analysis run + +Open Period report · Succeeded · Demo Corp, then Open period report +2026-W02. The report period field is focused. Failed rows stay +closed. Mean θ stays on the report panel. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4134cc94..ba848eea 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.97.0] - 2026-08-17 + +### Added + +- A Succeeded period-report analysis run now opens the scored week. + After `make seed`, open **Period report · Succeeded · Demo Corp** + and click **Open period report 2026-W02**: the report period field + is focused on that week. Failed rows stay closed. Mean θ stays on + the report panel. No TEPP theta is invented. + ## [0.96.0] - 2026-08-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 8f7bfda7..b8054592 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,5 +40,6 @@ frozen cutoff bag (ADR 0021 / ADR 0023) or submits TEPP through envelope is Failed. Failed TEPP is terminal — request a new run, then start. Do not invent a theta. Hover the Result prefix to read the parent-choice digest. -After `make seed`, open **Period report · Succeeded · Demo Corp** -to confirm the cutoff posts; mean θ stays on the period-report panel. +After `make seed`, open **Period report · Succeeded · Demo Corp**, +then **Open period report 2026-W02**. The report period field is +focused. Mean θ stays on the period-report panel. diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index fe27c9e7..f9108b9c 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -251,6 +251,8 @@ async def _serialize_runs( } if row["scope_entity_name"]: item["scope_entity_name"] = row["scope_entity_name"] + if row["scope_key"]: + item["scope_key"] = row["scope_key"] payload.append(item) return payload diff --git a/docs/adr/0024-seed-period-report-analysis-run.md b/docs/adr/0024-seed-period-report-analysis-run.md index 9ee8f90b..de8bf324 100644 --- a/docs/adr/0024-seed-period-report-analysis-run.md +++ b/docs/adr/0024-seed-period-report-analysis-run.md @@ -29,7 +29,8 @@ on a path that is not allowed to (ADR 0021 / ADR 0022 / ADR 0023). - `_seed_demo_period_report` still builds the calibrated report first. - `_seed_demo_report_run` then inserts `analysis_run_report` on the - same Demo Corp snapshot, scoped to the same corporate entity. + same Demo Corp snapshot, scoped to the same corporate entity, with + `scope_key` `2026-W02` so the home detail can open that week. - The lifecycle is Pending → Running → Succeeded because the report tables already hold the scored period. The run row stores only registry digests and counts — never a theta, item bank, or provider @@ -45,7 +46,8 @@ on a path that is not allowed to (ADR 0021 / ADR 0022 / ADR 0023). After `make seed`, Demo Analyst opens Analysis runs and sees **Period report · Succeeded · Demo Corp** next to the lineage and TEPP -rows. Opening it shows the cutoff posts. Mean θ remains on the +rows. Opening it shows the cutoff posts and **Open period report +2026-W02** (the week stored on `scope_key`). Mean θ remains on the period-report panel. Re-seed is idempotent on `demo-report-seed-2026-w02`. diff --git a/frontend/package.json b/frontend/package.json index 85873e81..d62e4676 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.96.0", + "version": "0.97.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 49c9e786..3d020ad4 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -179,7 +179,7 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/analysis-runs/run-demo-report")) { - const reportSucceeded = Boolean(options?.succeededReportRun); + const reportSucceeded = !options?.failedReportRun; return Promise.resolve( jsonResponse({ analysis_run_id: "run-demo-report", @@ -188,6 +188,7 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", + scope_key: "2026-W02", status_code: reportSucceeded ? "analysis_status_succeeded" : "analysis_status_failed", status_label: reportSucceeded ? "Succeeded" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", @@ -665,31 +666,28 @@ describe("App, authenticated", () => { }, ], }, - ...(options?.failedReportRun || options?.succeededReportRun - ? [ - { - analysis_run_id: "run-demo-report", - run_kind_code: "analysis_run_report" as const, - run_kind_label: "Period report", - scope_kind_code: "analysis_scope_corporate_entity", - scope_kind_label: "Corporate entity", - scope_entity_name: "Demo Corp", - status_code: options?.succeededReportRun - ? ("analysis_status_succeeded" as const) - : ("analysis_status_failed" as const), - status_label: options?.succeededReportRun ? "Succeeded" : "Failed", - knowledge_cutoff: "2026-01-12T12:00:00Z", - requested_at: "2026-01-12T12:38:00Z", - source_counts: [ - { - count_type_code: "analysis_count_document", - count_type_label: "Documents", - count_value: 3, - }, - ], - }, - ] - : []), + { + analysis_run_id: "run-demo-report", + run_kind_code: "analysis_run_report" as const, + run_kind_label: "Period report", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + scope_key: "2026-W02", + status_code: options?.failedReportRun + ? ("analysis_status_failed" as const) + : ("analysis_status_succeeded" as const), + status_label: options?.failedReportRun ? "Failed" : "Succeeded", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:38:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + }, ], }), ); @@ -1904,6 +1902,7 @@ describe("App, authenticated", () => { const list = screen.getByRole("list", { name: "Analysis runs" }); expect(list).toHaveTextContent("Lineage reconstruction · Succeeded · Demo Corp"); expect(list).toHaveTextContent("TEPP measurement · Failed · Demo Corp"); + expect(list).toHaveTextContent("Period report · Succeeded · Demo Corp"); expect(list).toHaveTextContent( "Open this run to see why it failed, then connect the measurement service and re-run.", ); @@ -2114,6 +2113,15 @@ describe("App, authenticated", () => { name: "Open live post: Public post", }), ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open period report 2026-W02" })).toBeInTheDocument(); + + const periodInput = screen.getByLabelText("Report period"); + await userEvent.clear(periodInput); + await userEvent.type(periodInput, "2026-W03"); + expect(periodInput).toHaveValue("2026-W03"); + await userEvent.click(screen.getByRole("button", { name: "Open period report 2026-W02" })); + expect(periodInput).toHaveValue("2026-W02"); + expect(periodInput).toHaveFocus(); }); it("does not tell a failed period report to connect the measurement service", async () => { @@ -2131,6 +2139,9 @@ describe("App, authenticated", () => { await userEvent.click(reportButton); expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Open period report 2026-W02" }), + ).not.toBeInTheDocument(); expect( await screen.findByText( "No posts were available at this cutoff for the period report. Open a later run, or ask an administrator to capture a newer snapshot.", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6bf05c4b..c6a3f570 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1681,6 +1681,28 @@ function analysisRunCanRequestTeppRetry(run: AnalysisRun): boolean { return run.run_kind_code === "analysis_run_tepp" && run.status_code === "analysis_status_failed"; } +const REPORT_PERIOD_KEY = /^\d{4}-W\d{2}$/; + +/** + * Period code stored on a succeeded report run's scope key. + * + * That key is a week label, not a theta. Missing or malformed keys + * stay closed so we do not invent a period. + */ +function analysisRunReportPeriod(run: AnalysisRun): string | null { + if (run.run_kind_code !== "analysis_run_report") { + return null; + } + if (run.status_code !== "analysis_status_succeeded") { + return null; + } + const key = run.scope_key; + if (!key || !REPORT_PERIOD_KEY.test(key)) { + return null; + } + return key; +} + /** * Open options for a reconstructed parent or child. * @@ -1699,9 +1721,11 @@ function analysisRunPostOpenOptions(run: AnalysisRun, postId: string): SelectPos function AnalysisRunsPanel({ accessToken, onSelectPost, + onSelectReportPeriod, }: { accessToken: string; onSelectPost: (postId: string, options?: SelectPostOptions) => void; + onSelectReportPeriod?: (periodCode: string) => void; }) { const [runs, setRuns] = useState(null); const [selected, setSelected] = useState(null); @@ -1872,6 +1896,21 @@ function AnalysisRunsPanel({ {requesting ? "Recording the run..." : "Request a new TEPP measurement"} )} + {analysisRunReportPeriod(selected) && onSelectReportPeriod && ( + + )} {selected.reconstructed_edges && selected.reconstructed_edges.length > 0 && (
              {selected.reconstructed_edges.map((edge) => ( @@ -2020,13 +2059,16 @@ function ReportsPanel({ accessToken, canRebuild, onSelectPost, + period, + onSelectPeriod, }: { accessToken: string; canRebuild: boolean; onSelectPost: (postId: string) => void; + period: string; + onSelectPeriod: (periodCode: string) => void; }) { const [grouping, setGrouping] = useState("process_unit"); - const [period, setPeriod] = useState("2026-W02"); const [payload, setPayload] = useState(null); const [index, setIndex] = useState(null); const [comparison, setComparison] = useState(null); @@ -2096,9 +2138,10 @@ function ReportsPanel({
          @@ -2128,7 +2171,7 @@ function ReportsPanel({
        )} + {openedGroupingLabel && ( +

        + {openedReportNextAction(openedGroupingLabel)} +

        + )} {index && index.periods.length > 0 && (
          {index.periods.map((row) => ( diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 2b38b4fc..107bfdb6 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.99.0" +__version__ = "1.0.0" diff --git a/pyproject.toml b/pyproject.toml index 4008b9f7..6510d6e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.99.0" +version = "1.0.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index 942b5fe1..7ea52e06 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.99.0" +version = "1.0.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 37a652c0592d34532719ca8ebf6bbbe6949c30e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 13:00:02 +0900 Subject: [PATCH 128/161] feat: land Demo Corp members under the opened-report next action (v1.1.0) (#203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Open period report names the next action, Demo Corp mean θ and member posts land immediately below it, ahead of Other Corp and the week strip. --- ....1.0-analysis-run-opened-report-members.md | 5 + CHANGELOG.md | 10 ++ CLAUDE.md | 7 +- .../0024-seed-period-report-analysis-run.md | 7 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 30 +++- frontend/src/App.tsx | 162 ++++++++++-------- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 10 files changed, 149 insertions(+), 80 deletions(-) create mode 100644 CHANGELOG.d/1.1.0-analysis-run-opened-report-members.md diff --git a/CHANGELOG.d/1.1.0-analysis-run-opened-report-members.md b/CHANGELOG.d/1.1.0-analysis-run-opened-report-members.md new file mode 100644 index 00000000..d4a9b2c0 --- /dev/null +++ b/CHANGELOG.d/1.1.0-analysis-run-opened-report-members.md @@ -0,0 +1,5 @@ +# 1.1.0 Land Demo Corp members under the opened-report next action + +Open period report 2026-W02 puts Demo Corp mean θ and member posts +immediately under the next action, ahead of Other Corp. Mean θ stays +on the report panel. diff --git a/CHANGELOG.md b/CHANGELOG.md index bd9a3aa0..0aa82319 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). +## [1.1.0] - 2026-08-17 + +### Added + +- Opening **Open period report 2026-W02** now puts the Demo Corp + report (mean θ and member posts) immediately under the named next + action, ahead of Other Corp and the week strip. The Public post + member stays clickable. Mean θ stays on the report panel. No TEPP + theta is invented. No cutoff body is invented (ADR 0016). + ## [1.0.0] - 2026-08-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 4f9b0b18..a9b7bfd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ then **Open period report 2026-W02**. The home week is already grouping is Corporate entity and Demo Corp is current. The focused chip name contains `Corporate entity: Demo Corp` and the persisted mean θ. The period-report panel says Demo Corp is the opened grouping -and to read its mean θ and member posts, then open a post. Changing -the week first still focuses the report period field. Mean θ stays on -the period-report panel. +and to read its mean θ and member posts, then open a post. Those +members land immediately under that next action, ahead of Other Corp +and the week strip. Changing the week first still focuses the report +period field. Mean θ stays on the period-report panel. diff --git a/docs/adr/0024-seed-period-report-analysis-run.md b/docs/adr/0024-seed-period-report-analysis-run.md index 9e824e7e..3a8e29c0 100644 --- a/docs/adr/0024-seed-period-report-analysis-run.md +++ b/docs/adr/0024-seed-period-report-analysis-run.md @@ -53,9 +53,10 @@ current, using the persisted scope grouping key. When the operator is already on that week, the comparison strip lands on Demo Corp. The focused chip name contains the visible Corporate entity caption and the persisted mean θ. The period-report panel names the next action: -read that grouping's mean θ and member posts, then open a post. Mean θ -remains on the period-report panel. Re-seed is idempotent on -`demo-report-seed-2026-w02`. +read that grouping's mean θ and member posts, then open a post. Those +members land immediately under that next action, ahead of other +groupings and the week strip. Mean θ remains on the period-report +panel. Re-seed is idempotent on `demo-report-seed-2026-w02`. ## References — APA 7th diff --git a/frontend/package.json b/frontend/package.json index 30a383f9..17ae5ab3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.0.0", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e0335f53..fbb93620 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import App from "./App"; @@ -2191,6 +2191,16 @@ describe("App, authenticated", () => { ); expect(await screen.findByText(/Demo Corp: mean θ 0\.42/)).toBeInTheDocument(); expect(screen.queryByText(/corp-1: mean θ/)).not.toBeInTheDocument(); + const openedReport = screen.getByRole("list", { name: "Opened grouping report" }); + expect(openedReport.textContent ?? "").toMatch(/Demo Corp: mean θ 0\.42[\s\S]*Other Corp: mean θ/); + expect( + within(openedReport).getByRole("button", { name: /open report post: public post/i }), + ).toBeInTheDocument(); + const status = screen.getByRole("status"); + const demoMean = screen.getByText(/Demo Corp: mean θ 0\.42/); + const weekChip = screen.getByRole("button", { name: /open report period 2026-W03/i }); + expect(status.compareDocumentPosition(demoMean) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); + expect(demoMean.compareDocumentPosition(weekChip) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); }); it("lands the comparison strip on Demo Corp when already on that week", async () => { @@ -2227,6 +2237,24 @@ describe("App, authenticated", () => { "Demo Corp is the opened grouping. Read its mean θ and member posts below, then open a post.", ); expect(await screen.findByText(/Demo Corp: mean θ 0\.42/)).toBeInTheDocument(); + const openedReport = screen.getByRole("list", { name: "Opened grouping report" }); + expect(within(openedReport).getByText(/Demo Corp: mean θ 0\.42/).closest("li")).toHaveAttribute( + "aria-current", + "true", + ); + expect(openedReport.textContent ?? "").toMatch(/Demo Corp: mean θ 0\.42[\s\S]*Other Corp: mean θ/); + expect(openedReport.textContent ?? "").not.toMatch(/Other Corp: mean θ[\s\S]*Demo Corp: mean θ 0\.42/); + const member = within(openedReport).getByRole("button", { + name: /open report post: public post/i, + }); + expect(member).toHaveTextContent("θ 0.91"); + const status = screen.getByRole("status"); + const demoMean = screen.getByText(/Demo Corp: mean θ 0\.42/); + const weekChip = screen.getByRole("button", { name: /open report period 2026-W03/i }); + expect(status.compareDocumentPosition(demoMean) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); + expect(demoMean.compareDocumentPosition(weekChip) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); + await userEvent.click(member); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); } finally { HTMLElement.prototype.scrollIntoView = originalScrollIntoView; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 921959e0..63eb0fc3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2116,6 +2116,18 @@ function openedReportNextAction(groupingLabel: string): string { ); } +function openedReportsFirst( + reports: T[], + isOpened: (groupingKey: string, groupingLabel?: string) => boolean, +): T[] { + const opened = reports.filter((report) => isOpened(report.grouping_key, report.grouping_label)); + if (opened.length === 0) { + return reports; + } + const rest = reports.filter((report) => !isOpened(report.grouping_key, report.grouping_label)); + return [...opened, ...rest]; +} + function ReportsPanel({ accessToken, canRebuild, @@ -2205,6 +2217,85 @@ function ReportsPanel({ } } + const orderedReports = payload + ? openedReportsFirst(payload.reports, (groupingKey, groupingLabel) => + groupingIsOpened(grouping, groupingKey, groupingLabel), + ) + : []; + const reportList = + payload === null && !error ? ( +

          Loading reports...

          + ) : payload && payload.reports.length === 0 ? ( +

          + No calibrated report for this grouping and period. Evaluate posts, then rebuild. +

          + ) : payload && payload.reports.length > 0 ? ( +
            + {orderedReports.map((report) => ( +
          • + + {report.grouping_label ?? report.grouping_key}: mean θ {report.mean_theta.toFixed(2)} ({report.selected_model} + {report.fit_converged ? ", converged" : ", not converged"}) + + {report.post_count} posts + {report.link_method === "fipc" && report.anchor_period_code && report.delta_mean_theta != null && ( + + vs {report.anchor_period_code}: {report.delta_mean_theta >= 0 ? "+" : ""} + {report.delta_mean_theta.toFixed(2)} + + )} + {report.link_method === "fipc" && report.delta_mean_theta == null && ( + shared metric + )} + {report.selected_items?.[0] && ( + + CAT: {criterionShortLabel(report.selected_items[0].item_code)} I= + {report.selected_items[0].information.toFixed(2)} + + )} + {report.members.length > 0 && ( +
              + {report.members.map((member) => ( +
            • + +
            • + ))} +
            + )} +
          • + ))} +
          + ) : null; + return (
          @@ -2275,6 +2366,7 @@ function ReportsPanel({ {openedReportNextAction(openedGroupingLabel)}

          )} + {openedGroupingLabel && reportList} {index && index.periods.length > 0 && (
            {index.periods.map((row) => ( @@ -2306,75 +2398,7 @@ function ReportsPanel({
          )} {error &&

          {error}

          } - {payload === null && !error &&

          Loading reports...

          } - {payload && payload.reports.length === 0 && ( -

          - No calibrated report for this grouping and period. Evaluate posts, then rebuild. -

          - )} - {payload && payload.reports.length > 0 && ( -
            - {payload.reports.map((report) => ( -
          • - - {report.grouping_label ?? report.grouping_key}: mean θ {report.mean_theta.toFixed(2)} ({report.selected_model} - {report.fit_converged ? ", converged" : ", not converged"}) - - {report.post_count} posts - {report.link_method === "fipc" && report.anchor_period_code && report.delta_mean_theta != null && ( - - vs {report.anchor_period_code}: {report.delta_mean_theta >= 0 ? "+" : ""} - {report.delta_mean_theta.toFixed(2)} - - )} - {report.link_method === "fipc" && report.delta_mean_theta == null && ( - shared metric - )} - {report.selected_items?.[0] && ( - - CAT: {criterionShortLabel(report.selected_items[0].item_code)} I= - {report.selected_items[0].information.toFixed(2)} - - )} - {report.members.length > 0 && ( -
              - {report.members.map((member) => ( -
            • - -
            • - ))} -
            - )} -
          • - ))} -
          - )} + {!openedGroupingLabel && reportList}
          ); } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 107bfdb6..fc6a0d64 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/pyproject.toml b/pyproject.toml index 6510d6e3..9b7dfcb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "1.0.0" +version = "1.1.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index 7ea52e06..e407904c 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "1.0.0" +version = "1.1.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 3bb3e8652c305a38a35a50a4363ba3db8c29a4a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 14:10:44 +0900 Subject: [PATCH 129/161] feat: name the next action on the opened Demo Corp member (v1.2.0) (#204) After Public post opens from the landed Demo Corp report, the panel says to read Event Lineage, Keyman, and evaluation on that post. --- ...-analysis-run-opened-member-next-action.md | 5 ++++ CHANGELOG.md | 10 +++++++ CLAUDE.md | 6 ++-- .../0024-seed-period-report-analysis-run.md | 6 ++-- frontend/package.json | 2 +- frontend/src/App.test.tsx | 9 ++++++ frontend/src/App.tsx | 29 +++++++++++++++++-- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 10 files changed, 63 insertions(+), 10 deletions(-) create mode 100644 CHANGELOG.d/1.2.0-analysis-run-opened-member-next-action.md diff --git a/CHANGELOG.d/1.2.0-analysis-run-opened-member-next-action.md b/CHANGELOG.d/1.2.0-analysis-run-opened-member-next-action.md new file mode 100644 index 00000000..b247d670 --- /dev/null +++ b/CHANGELOG.d/1.2.0-analysis-run-opened-member-next-action.md @@ -0,0 +1,5 @@ +# 1.2.0 Name the next action on the opened Demo Corp member + +Open Public post from the landed Demo Corp report. The panel says that +post is open and to read Event Lineage, Keyman, and evaluation. Mean θ +stays on the report panel. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aa82319..98bd3936 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). +## [1.2.0] - 2026-08-17 + +### Added + +- Opening **Public post** from the landed Demo Corp report now names + the next action: that post is open from Demo Corp, so read Event + Lineage, Keyman, and evaluation. The opened member is current. Mean + θ stays on the report panel. No TEPP theta is invented. No cutoff + body is invented (ADR 0016). + ## [1.1.0] - 2026-08-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index a9b7bfd7..f73c1550 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,5 +48,7 @@ chip name contains `Corporate entity: Demo Corp` and the persisted mean θ. The period-report panel says Demo Corp is the opened grouping and to read its mean θ and member posts, then open a post. Those members land immediately under that next action, ahead of Other Corp -and the week strip. Changing the week first still focuses the report -period field. Mean θ stays on the period-report panel. +and the week strip. Opening Public post names the next action: read +Event Lineage, Keyman, and evaluation on that post. Changing the week +first still focuses the report period field. Mean θ stays on the +period-report panel. diff --git a/docs/adr/0024-seed-period-report-analysis-run.md b/docs/adr/0024-seed-period-report-analysis-run.md index 3a8e29c0..686f89c0 100644 --- a/docs/adr/0024-seed-period-report-analysis-run.md +++ b/docs/adr/0024-seed-period-report-analysis-run.md @@ -55,8 +55,10 @@ focused chip name contains the visible Corporate entity caption and the persisted mean θ. The period-report panel names the next action: read that grouping's mean θ and member posts, then open a post. Those members land immediately under that next action, ahead of other -groupings and the week strip. Mean θ remains on the period-report -panel. Re-seed is idempotent on `demo-report-seed-2026-w02`. +groupings and the week strip. Opening a member post names the next +action: read Event Lineage, Keyman, and evaluation on that post. Mean +θ remains on the period-report panel. Re-seed is idempotent on +`demo-report-seed-2026-w02`. ## References — APA 7th diff --git a/frontend/package.json b/frontend/package.json index 17ae5ab3..887fc38d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.1.0", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fbb93620..0ffdea3a 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -2248,6 +2248,7 @@ describe("App, authenticated", () => { name: /open report post: public post/i, }); expect(member).toHaveTextContent("θ 0.91"); + expect(member).not.toHaveAttribute("aria-current"); const status = screen.getByRole("status"); const demoMean = screen.getByText(/Demo Corp: mean θ 0\.42/); const weekChip = screen.getByRole("button", { name: /open report period 2026-W03/i }); @@ -2255,6 +2256,14 @@ describe("App, authenticated", () => { expect(demoMean.compareDocumentPosition(weekChip) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); await userEvent.click(member); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(member).toHaveAttribute("aria-current", "true"); + expect(screen.getByRole("status")).toHaveTextContent( + "Public post is open from Demo Corp. Read Event Lineage, Keyman, and evaluation on this post.", + ); + expect(screen.getByRole("status")).not.toHaveTextContent("then open a post"); + expect( + screen.getAllByRole("heading", { name: "Event Lineage" }).length, + ).toBeGreaterThanOrEqual(2); } finally { HTMLElement.prototype.scrollIntoView = originalScrollIntoView; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 63eb0fc3..191e71da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2110,7 +2110,16 @@ function comparisonChipAccessibleName( return `Compare ${comparisonGroupingTitle(groupingKind, groupingLabel)}, mean θ ${meanTheta.toFixed(2)}`; } -function openedReportNextAction(groupingLabel: string): string { +function openedReportNextAction( + groupingLabel: string, + openedMemberTitle?: string | null, +): string { + if (openedMemberTitle) { + return ( + `${openedMemberTitle} is open from ${groupingLabel}. ` + + "Read Event Lineage, Keyman, and evaluation on this post." + ); + } return ( `${groupingLabel} is the opened grouping. Read its mean θ and member posts below, then open a post.` ); @@ -2140,6 +2149,7 @@ function ReportsPanel({ openedGroupingLabel, onOpenGrouping, landOnComparison, + selectedPostId, }: { accessToken: string; canRebuild: boolean; @@ -2152,6 +2162,7 @@ function ReportsPanel({ openedGroupingLabel?: string | null; onOpenGrouping?: (groupingKey: string, groupingLabel: string) => void; landOnComparison?: boolean; + selectedPostId?: string | null; }) { const [payload, setPayload] = useState(null); const [index, setIndex] = useState(null); @@ -2222,6 +2233,14 @@ function ReportsPanel({ groupingIsOpened(grouping, groupingKey, groupingLabel), ) : []; + const openedMemberTitle = selectedPostId + ? orderedReports + .filter((report) => + groupingIsOpened(grouping, report.grouping_key, report.grouping_label), + ) + .flatMap((report) => report.members) + .find((member) => member.post_id === selectedPostId)?.post_title + : undefined; const reportList = payload === null && !error ? (

          Loading reports...

          @@ -2271,6 +2290,11 @@ function ReportsPanel({
+ {focusEventLineage && ( + <> + + setEvaluation(rows)} + /> + + )} +

Affiliate tree

{affiliateTrees === null ? ( @@ -1436,17 +1461,19 @@ function PostDetailPopup({ )}
- + {!focusEventLineage && ( + + )} {counterparties && counterparties.length > 0 && ( Date: Mon, 17 Aug 2026 16:12:03 +0900 Subject: [PATCH 134/161] feat: name the first Keyman after landed evaluation (v1.7.0) (#209) Opening Public post from Demo Corp members now names the first Keyman after landed evaluation. Home list opens do not add that copy. --- CHANGELOG.d/1.7.0-name-first-keyman-after-eval.md | 4 ++++ CHANGELOG.md | 9 +++++++++ CLAUDE.md | 7 ++++--- frontend/package.json | 2 +- frontend/src/App.test.tsx | 11 ++++++++++- frontend/src/App.tsx | 9 +++++++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 9 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 CHANGELOG.d/1.7.0-name-first-keyman-after-eval.md diff --git a/CHANGELOG.d/1.7.0-name-first-keyman-after-eval.md b/CHANGELOG.d/1.7.0-name-first-keyman-after-eval.md new file mode 100644 index 00000000..a6683348 --- /dev/null +++ b/CHANGELOG.d/1.7.0-name-first-keyman-after-eval.md @@ -0,0 +1,4 @@ +# 1.7.0 Name the first Keyman after landed evaluation + +Open Public post from the landed Demo Corp members and the popup names +the first Keyman after landed evaluation. diff --git a/CHANGELOG.md b/CHANGELOG.md index 33f8e1de..ce0f9137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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). +## [1.7.0] - 2026-08-17 + +### Added + +- Opening Public post from the landed Demo Corp members now names the + first Keyman after landed evaluation: Ada West, then read that + person. Home list opens do not add that copy. No TEPP theta is + invented. No cutoff body is invented (ADR 0016). + ## [1.6.0] - 2026-08-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 92725c1a..14b58dda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,7 @@ members land immediately under that next action, ahead of Other Corp and the week strip. Opening Public post names the next action: read Event Lineage, Keyman, and evaluation on that post. The popup Event Lineage DAG marks that post current. After that current node, the -popup names Keyman and evaluation as the next read. Changing the week -first still focuses the report period field. Mean θ stays on the -period-report panel. +popup names Keyman and evaluation as the next read. After landed +evaluation, the popup names the first Keyman as the next read. +Changing the week first still focuses the report period field. Mean θ +stays on the period-report panel. diff --git a/frontend/package.json b/frontend/package.json index 7d93d754..47582abb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.6.0", + "version": "1.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a4139577..74e006b8 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1474,6 +1474,7 @@ describe("App, authenticated", () => { expect( screen.queryByRole("status", { name: "Event Lineage next action" }), ).not.toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Keyman next action" })).not.toBeInTheDocument(); const popup = document.querySelector(".popup-panel"); expect(popup).not.toBeNull(); const evaluation = within(popup as HTMLElement).getByRole("heading", { @@ -2311,8 +2312,13 @@ describe("App, authenticated", () => { 0, ); expect(keyman.compareDocumentPosition(evaluation) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); + const keymanNext = await screen.findByRole("status", { name: "Keyman next action" }); + expect(keymanNext).toHaveTextContent("Ada West is the first Keyman. Read that person next."); expect( - evaluation.compareDocumentPosition(affiliate) & Node.DOCUMENT_POSITION_FOLLOWING, + evaluation.compareDocumentPosition(keymanNext) & Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + expect( + keymanNext.compareDocumentPosition(affiliate) & Node.DOCUMENT_POSITION_FOLLOWING, ).not.toBe(0); } finally { HTMLElement.prototype.scrollIntoView = originalScrollIntoView; @@ -2581,6 +2587,9 @@ describe("App, authenticated", () => { expect(screen.getByRole("status", { name: "Event Lineage next action" })).toHaveTextContent( "Public post is current in Event Lineage. Read Keyman and evaluation next.", ); + expect(await screen.findByRole("status", { name: "Keyman next action" })).toHaveTextContent( + "Ada West is the first Keyman. Read that person next.", + ); }); it("lets post_admin rebuild the period report", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 57f3a203..1c0c36ac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -275,6 +275,10 @@ function eventLineageCurrentNextAction(postTitle: string): string { return `${postTitle} is current in Event Lineage. Read Keyman and evaluation next.`; } +function firstKeymanNextAction(personName: string): string { + return `${personName} is the first Keyman. Read that person next.`; +} + function EventLineageSection({ lineage, graph, @@ -1430,6 +1434,11 @@ function PostDetailPopup({ canExtract={canExtract} onEvaluated={(rows) => setEvaluation(rows)} /> + {keymen?.[0] ? ( +

+ {firstKeymanNextAction(keymen[0].person_name)} +

+ ) : null} )} diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 0d4e0f98..fa84379c 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "1.6.0" +__version__ = "1.7.0" diff --git a/pyproject.toml b/pyproject.toml index 58711595..b256d667 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "1.6.0" +version = "1.7.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index b1250c09..6c879e6e 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "1.6.0" +version = "1.7.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 3ace4f61d9d5a01dbbc73bd50bba184a922d901d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 16:16:53 +0900 Subject: [PATCH 135/161] feat: land first Keyman related nodes under the next action (v1.8.0) (#210) Opening Public post from Demo Corp members now lands Ada West related nodes under the first-Keyman next action, ahead of Affiliate tree. Home list opens still wait for a Keyman click. --- .../1.8.0-land-first-keyman-related.md | 5 + CHANGELOG.md | 9 + frontend/package.json | 2 +- frontend/src/App.test.tsx | 18 +- frontend/src/App.tsx | 237 ++++++++++-------- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 8 files changed, 168 insertions(+), 109 deletions(-) create mode 100644 CHANGELOG.d/1.8.0-land-first-keyman-related.md diff --git a/CHANGELOG.d/1.8.0-land-first-keyman-related.md b/CHANGELOG.d/1.8.0-land-first-keyman-related.md new file mode 100644 index 00000000..a3314ec7 --- /dev/null +++ b/CHANGELOG.d/1.8.0-land-first-keyman-related.md @@ -0,0 +1,5 @@ +# 1.8.0 Land first Keyman related nodes + +Open Public post from the landed Demo Corp members and Ada West related +nodes sit under the first-Keyman next action. Home list opens still +wait for a Keyman click. diff --git a/CHANGELOG.md b/CHANGELOG.md index ce0f9137..1735022b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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). +## [1.8.0] - 2026-08-17 + +### Added + +- Opening Public post from the landed Demo Corp members now lands + Ada West related nodes under the first-Keyman next action, ahead + of Affiliate tree. Home list opens still wait for a Keyman click. + No TEPP theta is invented. No cutoff body is invented (ADR 0016). + ## [1.7.0] - 2026-08-17 ### Added diff --git a/frontend/package.json b/frontend/package.json index 47582abb..037134f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.7.0", + "version": "1.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 74e006b8..4907de06 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1475,6 +1475,7 @@ describe("App, authenticated", () => { screen.queryByRole("status", { name: "Event Lineage next action" }), ).not.toBeInTheDocument(); expect(screen.queryByRole("status", { name: "Keyman next action" })).not.toBeInTheDocument(); + expect(screen.queryByText("Related to Ada West")).not.toBeInTheDocument(); const popup = document.querySelector(".popup-panel"); expect(popup).not.toBeNull(); const evaluation = within(popup as HTMLElement).getByRole("heading", { @@ -2314,12 +2315,22 @@ describe("App, authenticated", () => { expect(keyman.compareDocumentPosition(evaluation) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); const keymanNext = await screen.findByRole("status", { name: "Keyman next action" }); expect(keymanNext).toHaveTextContent("Ada West is the first Keyman. Read that person next."); + const related = await within(popup as HTMLElement).findByRole("heading", { + name: "Related to Ada West", + }); + expect(within(related.closest(".related-keymen") as HTMLElement).getByText(/Priya Nair/)).toBeInTheDocument(); expect( - evaluation.compareDocumentPosition(keymanNext) & Node.DOCUMENT_POSITION_FOLLOWING, - ).not.toBe(0); + within(popup as HTMLElement).getByRole("button", { name: "Related nodes for Ada West" }), + ).toHaveAttribute("aria-current", "true"); expect( - keymanNext.compareDocumentPosition(affiliate) & Node.DOCUMENT_POSITION_FOLLOWING, + evaluation.compareDocumentPosition(keymanNext) & Node.DOCUMENT_POSITION_FOLLOWING, ).not.toBe(0); + expect(keymanNext.compareDocumentPosition(related) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe( + 0, + ); + expect(related.compareDocumentPosition(affiliate) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe( + 0, + ); } finally { HTMLElement.prototype.scrollIntoView = originalScrollIntoView; } @@ -2590,6 +2601,7 @@ describe("App, authenticated", () => { expect(await screen.findByRole("status", { name: "Keyman next action" })).toHaveTextContent( "Ada West is the first Keyman. Read that person next.", ); + expect(await screen.findByRole("heading", { name: "Related to Ada West" })).toBeInTheDocument(); }); it("lets post_admin rebuild the period report", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c0c36ac..fc727d1b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -563,6 +563,8 @@ function KeymanPanel({ focusPerson, focusEntity, focusTeam, + landFirstKeyman, + afterList, }: { postId: string; accessToken: string; @@ -573,6 +575,8 @@ function KeymanPanel({ focusPerson?: { personId: string; personName: string } | null; focusEntity?: { entityId: string; entityName: string } | null; focusTeam?: { teamId: string; teamName: string } | null; + landFirstKeyman?: boolean; + afterList?: ReactNode; }) { const [related, setRelated] = useState(null); const [selectedName, setSelectedName] = useState(null); @@ -622,6 +626,23 @@ function KeymanPanel({ } } + useEffect(() => { + if (!landFirstKeyman || selectedName || !keymen?.[0]) { + return; + } + const first = keymen[0]; + const requestId = ++relatedRequest.current; + setSelectedName(first.person_name); + setRelated(null); + fetchRelatedKeymen(accessToken, first.person_id) + .then((result) => { + if (requestId === relatedRequest.current) setRelated(result.related); + }) + .catch(() => { + if (requestId === relatedRequest.current) setRelated([]); + }); + }, [accessToken, landFirstKeyman, keymen, selectedName]); + useEffect(() => { if (!focusPerson) return; const requestId = ++relatedRequest.current; @@ -680,7 +701,86 @@ function KeymanPanel({ } } + const relatedBlock = selectedName ? ( +
+

Related to {selectedName}

+ {related === null ? ( +

Loading related nodes...

+ ) : related.length === 0 ? ( +

No related nodes in the visible graph.

+ ) : ( +
    + {related.map((node) => { + const caption = relatedNodeCaption(node); + const key = `${node.node_type_code}:${node.node_id}`; + if (!isKnownRelatedNodeType(node.node_type_code)) { + return
  • {caption}
  • ; + } + switch (node.node_type_code) { + case NODE_POST: + if (!onSelectPost) { + return
  • {caption}
  • ; + } + return ( +
  • + +
  • + ); + case NODE_PERSON: + return ( +
  • + +
  • + ); + case NODE_CORPORATE_ENTITY: + return ( +
  • + +
  • + ); + case NODE_TEAM: + return ( +
  • + +
  • + ); + default: { + const _exhaustive: never = node.node_type_code; + return
  • {_exhaustive}
  • ; + } + } + })} +
+ )} +
+ ) : null; + return ( + <>

Keyman

@@ -698,6 +798,9 @@ function KeymanPanel({ - - ); - case NODE_PERSON: - return ( -
  • - -
  • - ); - case NODE_CORPORATE_ENTITY: - return ( -
  • - -
  • - ); - case NODE_TEAM: - return ( -
  • - -
  • - ); - default: { - const _exhaustive: never = node.node_type_code; - return
  • {_exhaustive}
  • ; - } - } - })} - - )} -
    - )} + {!afterList && relatedBlock}
    + {afterList} + {afterList && relatedBlock} + ); } @@ -1415,31 +1445,34 @@ function PostDetailPopup({ {focusEventLineage && ( - <> - - setEvaluation(rows)} - /> - {keymen?.[0] ? ( -

    - {firstKeymanNextAction(keymen[0].person_name)} -

    - ) : null} - + + setEvaluation(rows)} + /> + {keymen?.[0] ? ( +

    + {firstKeymanNextAction(keymen[0].person_name)} +

    + ) : null} + + } + /> )}
    diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index fa84379c..502ca14c 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "1.7.0" +__version__ = "1.8.0" diff --git a/pyproject.toml b/pyproject.toml index b256d667..52d04d72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "1.7.0" +version = "1.8.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index 6c879e6e..f6526a6a 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "1.7.0" +version = "1.8.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 60a73fdff586fe0bce0a7755304c70db886ebc48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 17:29:43 +0900 Subject: [PATCH 136/161] feat: name the first related node after landed Ada West related (v1.9.0) Opening Public post from Demo Corp members now names the first related node after landed Ada West related. Home list opens do not add that copy. --- CHANGELOG.d/1.9.0-name-first-related-after-ada.md | 4 ++++ CHANGELOG.md | 9 +++++++++ CLAUDE.md | 7 ++++--- frontend/package.json | 2 +- frontend/src/App.test.tsx | 13 ++++++++++++- frontend/src/App.tsx | 9 +++++++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 9 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 CHANGELOG.d/1.9.0-name-first-related-after-ada.md diff --git a/CHANGELOG.d/1.9.0-name-first-related-after-ada.md b/CHANGELOG.d/1.9.0-name-first-related-after-ada.md new file mode 100644 index 00000000..2f36620a --- /dev/null +++ b/CHANGELOG.d/1.9.0-name-first-related-after-ada.md @@ -0,0 +1,4 @@ +# 1.9.0 Name the first related node after Ada West related + +Open Public post from the landed Demo Corp members and the popup names +the first related node after landed Ada West related. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1735022b..51c3a60b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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). +## [1.9.0] - 2026-08-17 + +### Added + +- Opening Public post from the landed Demo Corp members now names the + first related node after landed Ada West related: Priya Nair, then + read that person. Home list opens do not add that copy. No TEPP + theta is invented. No cutoff body is invented (ADR 0016). + ## [1.8.0] - 2026-08-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 14b58dda..7b5ca8ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ and the week strip. Opening Public post names the next action: read Event Lineage, Keyman, and evaluation on that post. The popup Event Lineage DAG marks that post current. After that current node, the popup names Keyman and evaluation as the next read. After landed -evaluation, the popup names the first Keyman as the next read. -Changing the week first still focuses the report period field. Mean θ -stays on the period-report panel. +evaluation, the popup names the first Keyman as the next read. After +landed Ada West related, the popup names the first related node as +the next read. Changing the week first still focuses the report +period field. Mean θ stays on the period-report panel. diff --git a/frontend/package.json b/frontend/package.json index 037134f3..72127cf5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.8.0", + "version": "1.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 4907de06..c2640786 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1475,6 +1475,7 @@ describe("App, authenticated", () => { screen.queryByRole("status", { name: "Event Lineage next action" }), ).not.toBeInTheDocument(); expect(screen.queryByRole("status", { name: "Keyman next action" })).not.toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Related next action" })).not.toBeInTheDocument(); expect(screen.queryByText("Related to Ada West")).not.toBeInTheDocument(); const popup = document.querySelector(".popup-panel"); expect(popup).not.toBeNull(); @@ -2328,7 +2329,14 @@ describe("App, authenticated", () => { expect(keymanNext.compareDocumentPosition(related) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe( 0, ); - expect(related.compareDocumentPosition(affiliate) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe( + const relatedNext = await screen.findByRole("status", { name: "Related next action" }); + expect(relatedNext).toHaveTextContent( + "Priya Nair is the first related node. Read that person next.", + ); + expect(related.compareDocumentPosition(relatedNext) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe( + 0, + ); + expect(relatedNext.compareDocumentPosition(affiliate) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe( 0, ); } finally { @@ -2602,6 +2610,9 @@ describe("App, authenticated", () => { "Ada West is the first Keyman. Read that person next.", ); expect(await screen.findByRole("heading", { name: "Related to Ada West" })).toBeInTheDocument(); + expect(await screen.findByRole("status", { name: "Related next action" })).toHaveTextContent( + "Priya Nair is the first related node. Read that person next.", + ); }); it("lets post_admin rebuild the period report", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fc727d1b..047f5b63 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -279,6 +279,10 @@ function firstKeymanNextAction(personName: string): string { return `${personName} is the first Keyman. Read that person next.`; } +function firstRelatedNextAction(nodeLabel: string): string { + return `${nodeLabel} is the first related node. Read that person next.`; +} + function EventLineageSection({ lineage, graph, @@ -847,6 +851,11 @@ function KeymanPanel({
    {afterList} {afterList && relatedBlock} + {afterList && related?.[0] ? ( +

    + {firstRelatedNextAction(related[0].label ?? related[0].node_id)} +

    + ) : null} ); } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 502ca14c..13ada4e3 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "1.8.0" +__version__ = "1.9.0" diff --git a/pyproject.toml b/pyproject.toml index 52d04d72..8469b1f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "1.8.0" +version = "1.9.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index f6526a6a..39c46d58 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "1.8.0" +version = "1.9.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From d4d1048467f8573fc6a9c3b45b556f072889583a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 17:32:17 +0900 Subject: [PATCH 137/161] feat: land first related nodes under the next action (v2.0.0) (#216) Opening Public post from Demo Corp members now lands Priya Nair related nodes under the first-related next action, ahead of Affiliate tree. Home list opens still wait for a related click. --- .../2.0.0-land-first-related-after-priya.md | 4 ++ CHANGELOG.md | 9 ++++ CLAUDE.md | 3 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 18 ++++++- frontend/src/App.tsx | 54 +++++++++++++++++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 9 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.d/2.0.0-land-first-related-after-priya.md diff --git a/CHANGELOG.d/2.0.0-land-first-related-after-priya.md b/CHANGELOG.d/2.0.0-land-first-related-after-priya.md new file mode 100644 index 00000000..2a45ea23 --- /dev/null +++ b/CHANGELOG.d/2.0.0-land-first-related-after-priya.md @@ -0,0 +1,4 @@ +# 2.0.0 Land the first related node after Priya Nair is named + +Open Public post from the landed Demo Corp members and the popup lands +Priya Nair related nodes under the first-related next action. diff --git a/CHANGELOG.md b/CHANGELOG.md index 51c3a60b..735156c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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.0.0] - 2026-08-17 + +### Added + +- Opening Public post from the landed Demo Corp members now lands + Priya Nair related nodes under the first-related next action, ahead + of Affiliate tree. Home list opens still wait for a related click. + No TEPP theta is invented. No cutoff body is invented (ADR 0016). + ## [1.9.0] - 2026-08-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 7b5ca8ca..096852a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,5 +54,6 @@ Lineage DAG marks that post current. After that current node, the popup names Keyman and evaluation as the next read. After landed evaluation, the popup names the first Keyman as the next read. After landed Ada West related, the popup names the first related node as -the next read. Changing the week first still focuses the report +the next read. After that next action, the popup lands Priya Nair +related nodes. Changing the week first still focuses the report period field. Mean θ stays on the period-report panel. diff --git a/frontend/package.json b/frontend/package.json index 72127cf5..24240f91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.9.0", + "version": "2.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index c2640786..b4fab0d2 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1477,6 +1477,7 @@ describe("App, authenticated", () => { expect(screen.queryByRole("status", { name: "Keyman next action" })).not.toBeInTheDocument(); expect(screen.queryByRole("status", { name: "Related next action" })).not.toBeInTheDocument(); expect(screen.queryByText("Related to Ada West")).not.toBeInTheDocument(); + expect(screen.queryByText("Related to Priya Nair")).not.toBeInTheDocument(); const popup = document.querySelector(".popup-panel"); expect(popup).not.toBeNull(); const evaluation = within(popup as HTMLElement).getByRole("heading", { @@ -2333,10 +2334,24 @@ describe("App, authenticated", () => { expect(relatedNext).toHaveTextContent( "Priya Nair is the first related node. Read that person next.", ); + expect( + within(popup as HTMLElement).getByRole("button", { + name: "Related nodes for Priya Nair (Counterparty)", + }), + ).toHaveAttribute("aria-current", "true"); + const landedRelated = await within(popup as HTMLElement).findByRole("heading", { + name: "Related to Priya Nair", + }); + expect( + within(landedRelated.closest(".related-keymen") as HTMLElement).getByText(/Ada West/), + ).toBeInTheDocument(); expect(related.compareDocumentPosition(relatedNext) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe( 0, ); - expect(relatedNext.compareDocumentPosition(affiliate) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe( + expect( + relatedNext.compareDocumentPosition(landedRelated) & Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + expect(landedRelated.compareDocumentPosition(affiliate) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe( 0, ); } finally { @@ -2613,6 +2628,7 @@ describe("App, authenticated", () => { expect(await screen.findByRole("status", { name: "Related next action" })).toHaveTextContent( "Priya Nair is the first related node. Read that person next.", ); + expect(await screen.findByRole("heading", { name: "Related to Priya Nair" })).toBeInTheDocument(); }); it("lets post_admin rebuild the period report", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 047f5b63..e1db31c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -568,6 +568,7 @@ function KeymanPanel({ focusEntity, focusTeam, landFirstKeyman, + landFirstRelated, afterList, }: { postId: string; @@ -580,10 +581,13 @@ function KeymanPanel({ focusEntity?: { entityId: string; entityName: string } | null; focusTeam?: { teamId: string; teamName: string } | null; landFirstKeyman?: boolean; + landFirstRelated?: boolean; afterList?: ReactNode; }) { const [related, setRelated] = useState(null); const [selectedName, setSelectedName] = useState(null); + const [landedRelated, setLandedRelated] = useState(null); + const [landedRelatedName, setLandedRelatedName] = useState(null); const [extracting, setExtracting] = useState(false); const [error, setError] = useState(null); const [orchestratorOff, setOrchestratorOff] = useState(false); @@ -647,6 +651,32 @@ function KeymanPanel({ }); }, [accessToken, landFirstKeyman, keymen, selectedName]); + useEffect(() => { + if (!landFirstRelated) { + setLandedRelatedName(null); + setLandedRelated(null); + return; + } + const first = related?.[0]; + if (!first || first.node_type_code !== NODE_PERSON) { + return; + } + const name = first.label ?? first.node_id; + let cancelled = false; + setLandedRelatedName(name); + setLandedRelated(null); + fetchRelatedKeymen(accessToken, first.node_id) + .then((result) => { + if (!cancelled) setLandedRelated(result.related); + }) + .catch(() => { + if (!cancelled) setLandedRelated([]); + }); + return () => { + cancelled = true; + }; + }, [accessToken, landFirstRelated, related]); + useEffect(() => { if (!focusPerson) return; const requestId = ++relatedRequest.current; @@ -742,6 +772,11 @@ function KeymanPanel({ - {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 && ( + ); + } else if (person) { actorName = ( - {(error || entitiesLoadError) &&

    {error ?? entitiesLoadError}

    } + {statusMessage ? {statusMessage} : null} {runs.length === 0 ? (

    No analysis runs visible to this account yet. Request a lineage diff --git a/frontend/src/components/StatusAlert.stories.tsx b/frontend/src/components/StatusAlert.stories.tsx new file mode 100644 index 00000000..947aeedf --- /dev/null +++ b/frontend/src/components/StatusAlert.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StatusAlert } from "./StatusAlert"; + +const meta = { + title: "Chrome/StatusAlert", + component: StatusAlert, + args: { + children: + "This run is not on your list. Open a visible run from the home list, or request a lineage reconstruction for a corporation you already walk.", + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const HiddenAnalysisRun: Story = {}; + +export const ListLoadFailure: Story = { + args: { + children: "BackendError: 503 Service Unavailable", + }, +}; diff --git a/frontend/src/components/StatusAlert.test.tsx b/frontend/src/components/StatusAlert.test.tsx new file mode 100644 index 00000000..ebca0552 --- /dev/null +++ b/frontend/src/components/StatusAlert.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { StatusAlert } from "./StatusAlert"; + +describe("StatusAlert", () => { + it("announces the next action as an alert without moving focus", () => { + render( + + This run is not on your list. Open a visible run from the home list, or request a lineage reconstruction for a corporation you already walk. + , + ); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent( + "This run is not on your list. Open a visible run from the home list, or request a lineage reconstruction for a corporation you already walk.", + ); + expect(alert.tagName).toBe("P"); + expect(document.activeElement).not.toBe(alert); + expect(screen.queryByText(/not visible/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/thread-group/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/knowledge_cutoff/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/StatusAlert.tsx b/frontend/src/components/StatusAlert.tsx new file mode 100644 index 00000000..f8710b88 --- /dev/null +++ b/frontend/src/components/StatusAlert.tsx @@ -0,0 +1,18 @@ +export type StatusAlertProps = { + children: string; +}; + +/** + * Announces a fail-closed status so the operator hears the next action. + * + * Uses `role="alert"` (WCAG 2.2 SC 4.1.3) without moving focus. + * Next action: read the sentence, then use the control it names + * (open a visible run, or request a lineage reconstruction). + */ +export function StatusAlert({ children }: StatusAlertProps) { + return ( +

    + {children} +

    + ); +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index fc92405f..7722cb16 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -8,6 +8,7 @@ --color-accent-background: rgba(170, 59, 255, 0.1); --color-accent-border: rgba(170, 59, 255, 0.5); --color-chip-border: #3335; + --color-status-alert: #b91c1c; --space-chip-inline: 0.6rem; --space-chip-block: 0.1rem; --space-chip-gap: 0.3rem; @@ -34,5 +35,6 @@ --color-accent-background: rgba(192, 132, 252, 0.15); --color-accent-border: rgba(192, 132, 252, 0.5); --color-chip-border: #9ca3af; + --color-status-alert: #f87171; } } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 8fda891a..aa53eb9e 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.10.2" +__version__ = "2.10.3" diff --git a/pyproject.toml b/pyproject.toml index b04c3ca2..5cb37508 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.10.2" +version = "2.10.3" 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_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py index 5a4e284b..55a94ab4 100644 --- a/tests/test_ingestion_transaction_contracts.py +++ b/tests/test_ingestion_transaction_contracts.py @@ -642,6 +642,36 @@ async def persist_edges(conn, post_id) -> list[Any]: assert mention_inserts == [] +def test_hidden_run_copy_stays_generic_and_drops_the_stale_row() -> None: + """ADR 0014/0018: a 404 must not confirm why a row is hidden.""" + + app = ( + Path(__file__).resolve().parents[1] / "frontend" / "src" / "App.tsx" + ).read_text(encoding="utf-8") + alert = ( + Path(__file__).resolve().parents[1] + / "frontend" + / "src" + / "components" + / "StatusAlert.tsx" + ).read_text(encoding="utf-8") + agents = ( + Path(__file__).resolve().parents[1] / "AGENTS.md" + ).read_text(encoding="utf-8") + assert "This analysis run is not visible." not in app + assert "This run is not on your list. Open a visible run from the home list," in app + assert ( + "or request a lineage reconstruction for a corporation you already walk." + in app + ) + assert "do not name the thread or the cutoff" in app + assert "setRuns((await fetchAnalysisRuns(accessToken)).analysis_runs)" in app + assert 'role="alert"' in alert + assert "{error}" in app + assert "re-read the authorized list" in agents + assert "do not name the thread or the cutoff" in agents + + def test_role_catalog_identity_is_stored_on_the_role_row() -> None: """ADR 0019: fetch must not reconstruct organization identity by name.""" root = Path(__file__).resolve().parents[1] diff --git a/uv.lock b/uv.lock index 127875bd..025598df 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.10.2" +version = "2.10.3" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 8215cc2ae7d956788cd8bf21894104d65a687a68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:10:00 +0900 Subject: [PATCH 154/161] fix: empty reconstruction children during granted retention purge (v2.10.4) Successor to dirty #177. Granted retention purge empties reconstruction children without a superuser DISABLE TRIGGER. Version 2.10.4. Do not merge #74 onto main. --- AGENTS.md | 5 +- ARCHITECTURE.md | 5 +- ...retention-purge-reconstruction-children.md | 6 + CHANGELOG.md | 11 ++ CLAUDE.md | 11 +- README.md | 4 +- docs/adr/0020-analysis-run-retention-purge.md | 9 ++ ...retention-purge-reconstruction-children.md | 62 +++++++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 2 +- frontend/package.json | 2 +- lineageweave/__init__.py | 2 +- .../0020_analysis_run_retention_purge.sql | 47 ++++++- migrations/0023_analysis_run_outbox.sql | 8 +- pyproject.toml | 2 +- tests/test_analysis_run_registry_schema.py | 130 ++++++++++++++++++ 15 files changed, 288 insertions(+), 18 deletions(-) create mode 100644 CHANGELOG.d/2.10.4-retention-purge-reconstruction-children.md create mode 100644 docs/adr/0032-retention-purge-reconstruction-children.md diff --git a/AGENTS.md b/AGENTS.md index c9769131..ae19095b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,8 +97,9 @@ pnpm run lint && pnpm run test && pnpm run build A run-bearing analysis-run registry empties only after an unrevoked `analysis_run_retention_grant` and `GRANT analysis_run_retention_admin` -(ADR 0020 / v0.87.0). The documented phrase is not a secret. Do not -expose purge on a public HTTP route. +(ADR 0020 / v0.87.0). The documented phrase is not a secret. The same +call empties reconstruction children when those tables exist +(ADR 0032 / v2.10.4). 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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0525a018..5307a0d8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -531,7 +531,10 @@ A run-bearing registry is emptied only after an unrevoked `analysis_run_retention_grant` and `GRANT analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')` (ADR 0020); a raw `DELETE` and a runtime role that only knows the -public phrase stay rejected. Repeated chip and close controls use +public phrase stay rejected. When start reconstruction has persisted +run-scoped edges, that same call empties those children instead of +stopping on an immutable-trigger or foreign-key error (ADR 0032). +Repeated chip and close controls use `frontend/src/styles/tokens.css` and the Storybook inventory. ## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only) diff --git a/CHANGELOG.d/2.10.4-retention-purge-reconstruction-children.md b/CHANGELOG.d/2.10.4-retention-purge-reconstruction-children.md new file mode 100644 index 00000000..1343d758 --- /dev/null +++ b/CHANGELOG.d/2.10.4-retention-purge-reconstruction-children.md @@ -0,0 +1,6 @@ +# 2.10.4 Empty reconstruction children during granted retention purge + +After a Demo Corp lineage reconstruction has started, the same +granted retention purge empties reconstruction edges, the +reconstruction digest, and frozen snapshot members (ADR 0032). +Do not `DISABLE TRIGGER` as superuser. diff --git a/CHANGELOG.md b/CHANGELOG.md index f2dff8f6..9c836ba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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.10.4] - 2026-08-17 + +### Fixed + +- After a Demo Corp lineage reconstruction has started, the same + granted retention purge empties `analysis_run_lineage_edge`, + `analysis_run_reconstruction`, and `analysis_source_snapshot_member` + when those tables exist, including their delete-reject triggers + (ADR 0032). Follow the same grant + admin + phrase path — do not + `DISABLE TRIGGER` as superuser. + ## [2.10.3] - 2026-08-17 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index c9636ef6..34ffdc39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,11 +10,12 @@ To empty a run-bearing registry, insert an unrevoked `GRANT analysis_run_retention_admin` (ADR 0020). Then `select purge_analysis_run_registry('approved-retention-purge')`, export `analysis_run_retention_event`, delete those rows, and roll -back 0020 then 0018. The published phrase is not a secret. Do not -`DISABLE TRIGGER` as superuser. Do not grant the admin role or a -retention grant to the application `DATABASE_URL` login. ADR 0019 -is the R&R catalog-id bind, not this purge. Person catalog identity -on that role row is ADR 0027 (`cataloged_person_id`). +back 0020 then 0018. The same call empties reconstruction children +when ADR 0021 tables exist (ADR 0032). The published phrase is not a +secret. Do not `DISABLE TRIGGER` as superuser. Do not grant the admin +role or a retention grant to the application `DATABASE_URL` login. +ADR 0019 is the R&R catalog-id bind, not this purge. Person catalog +identity on that role row is ADR 0027 (`cataloged_person_id`). ## Analysis-run seed (v0.96.0) diff --git a/README.md b/README.md index 52b54b71..c143f84f 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,9 @@ cd frontend && cp .env.example .env.local && pnpm install && pnpm run dev # Empty a run-bearing registry: insert analysis_run_retention_grant # for session_user, GRANT analysis_run_retention_admin, then # select purge_analysis_run_registry('approved-retention-purge'). -# The published token is not a grant (ADR 0020). +# The published token is not a grant (ADR 0020). After a Succeeded +# start, that same call also empties reconstruction children +# (ADR 0032). # -> http://localhost:5173, click "Log in", redirects through the real # Keycloak login page for demo.analyst / lineageweave-demo-only ``` diff --git a/docs/adr/0020-analysis-run-retention-purge.md b/docs/adr/0020-analysis-run-retention-purge.md index 928ba87c..5d294c41 100644 --- a/docs/adr/0020-analysis-run-retention-purge.md +++ b/docs/adr/0020-analysis-run-retention-purge.md @@ -76,6 +76,15 @@ operators who purge from `psql`. Do not expose purge on a public HTTP route. Split the application login from the migration owner so the product role cannot execute the function even as table owner. +Start reconstruction (ADR 0021) adds `analysis_run_lineage_edge`, +`analysis_run_reconstruction`, and `analysis_source_snapshot_member` +with delete-reject triggers. This procedure already disables those +user triggers when `to_regclass` finds the tables, deletes lineage +edges, then reconstruction, then the 0018 rows, then snapshot +members, then the snapshot, and re-enables the triggers (ADR 0032). +A 0020-only database without those relations still purges. Do not +require a superuser `DISABLE TRIGGER` after a Succeeded start. + ## References — APA 7th American Institute of Certified Public Accountants. (2017). *SOC 2®: SOC diff --git a/docs/adr/0032-retention-purge-reconstruction-children.md b/docs/adr/0032-retention-purge-reconstruction-children.md new file mode 100644 index 00000000..16fbf7a9 --- /dev/null +++ b/docs/adr/0032-retention-purge-reconstruction-children.md @@ -0,0 +1,62 @@ +# ADR 0032 — Granted retention purge empties reconstruction children + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-17 +**Depends on:** ADR 0020 granted retention purge; ADR 0021 start reconstruction + +## Context + +ADR 0020 added `purge_analysis_run_registry` so a run-bearing registry +can empty without a superuser `DISABLE TRIGGER`. ADR 0021 then persisted +immutable `analysis_run_lineage_edge`, `analysis_run_reconstruction`, +and `analysis_source_snapshot_member` rows. After a Demo Corp lineage +reconstruction has started, those children and their delete-reject +triggers would fail-close the published grant + admin + phrase path +(ISO 15489-1:2016 disposition; NIST SP 800-92 protected audit records). + +Dirty draft #177 ported this procedure onto a stale 0.87.0-only head. +This decision is the successor on live #74 after v2.10.3. It does not +start reconstruction, invent a theta, expose purge on a public HTTP +route, or grant `analysis_run_retention_admin` to `DATABASE_URL`. + +## Decision + +`purge_analysis_run_registry` disables user triggers on the three +optional children when `to_regclass` finds them, deletes in this +order, then re-enables the triggers on success and in the exception +handler: + +1. `analysis_run_lineage_edge` +2. `analysis_run_reconstruction` +3. migration 0018 registry rows (`analysis_run_status_event`, + `analysis_run_scope`, `analysis_run`, `analysis_source_count`) +4. `analysis_source_snapshot_member` +5. `analysis_source_snapshot` + +A database without those relations still purges. Operators follow the +same unrevoked `analysis_run_retention_grant`, +`GRANT analysis_run_retention_admin`, and +`approved-retention-purge` phrase (ADR 0020). Do not `DISABLE TRIGGER` +as superuser after a Succeeded start. + +## Consequences + +- After a Demo Corp lineage reconstruction has started, the granted + retention purge still empties the registry. +- Missing child tables stay a no-op. A 0020-only database still + purges. +- Purge remains an audited SQL operator action, not a public route. + +## References — APA 7th + +International Organization for Standardization. (2016). *ISO +15489-1:2016: Information and documentation—Records management—Part 1: +Concepts and principles*. + +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 + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 +documentation: 9.29. System information functions and operators*. +https://www.postgresql.org/docs/current/functions-info.html diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index 2b963cf6..81b17866 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -79,7 +79,7 @@ provenance, retention, and immutable evidence rather than blanket masking. | Request identity is stable | Reject analysis-run updates; scope and lifecycle live in their own relations. | | Idempotency is actor-scoped | Permit identical opaque keys for two accounts and reject reuse by the same account. | | Lifecycle is ordered | Require pending first, contiguous ordinals, monotonic time, legal transitions, terminal finality, and append-only rows. | -| Rollback does not erase audit data silently | Reject 0018 rollback with any registry rows. A run-bearing registry empties only through an unrevoked `analysis_run_retention_grant` plus `analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')`; a wrong token, a raw `DELETE`, and a runtime role that only knows the public phrase stay rejected. Export then delete `analysis_run_retention_event` before 0020 rollback. | +| Rollback does not erase audit data silently | Reject 0018 rollback with any registry rows. A run-bearing registry empties only through an unrevoked `analysis_run_retention_grant` plus `analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')`; a wrong token, a raw `DELETE`, and a runtime role that only knows the public phrase stay rejected. When `analysis_run_reconstruction` children exist, that same call empties them despite delete-reject triggers (ADR 0032). Export then delete `analysis_run_retention_event` before 0020 rollback. | | Start reconstruction recovers the designed tree | Persist edges from `lineage_edge_specs` on the A-100 fixture bag via `records_from_source_posts`; the pricing follow-up must parent both the revised quote and the delivery question. A period-report start must 422 without a theta. TEPP start submits through `tepp_client` and stays Failed (`tepp_not_available` / `tepp_result_not_persisted`) without a theta. Snapshot members exclude a later backfill. A concurrent or Running start is 409. A Succeeded retry returns the stored digest. | ## APA 7th references diff --git a/frontend/package.json b/frontend/package.json index 709b32e5..f191172d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.10.3", + "version": "2.10.4", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index aa53eb9e..7e9c6fa7 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.10.3" +__version__ = "2.10.4" diff --git a/migrations/0020_analysis_run_retention_purge.sql b/migrations/0020_analysis_run_retention_purge.sql index 056d8021..311fa028 100644 --- a/migrations/0020_analysis_run_retention_purge.sql +++ b/migrations/0020_analysis_run_retention_purge.sql @@ -118,6 +118,20 @@ begin select count(*) into run_count from analysis_run; select count(*) into snapshot_count from analysis_source_snapshot; + -- ADR 0021 start reconstruction adds immutable children. Those + -- tables are absent on a 0020-only database. When present, their + -- delete-reject triggers and FKs would otherwise force a superuser + -- DISABLE TRIGGER — the failure this procedure exists to remove. + if to_regclass('public.analysis_run_lineage_edge') is not null then + execute 'alter table analysis_run_lineage_edge disable trigger user'; + end if; + if to_regclass('public.analysis_run_reconstruction') is not null then + execute 'alter table analysis_run_reconstruction disable trigger user'; + end if; + if to_regclass('public.analysis_source_snapshot_member') is not null then + execute 'alter table analysis_source_snapshot_member disable trigger user'; + end if; + alter table analysis_run_status_event disable trigger analysis_run_status_event_delete_reject; alter table analysis_run_scope @@ -126,10 +140,19 @@ begin disable trigger analysis_run_mutation_reject; begin + if to_regclass('public.analysis_run_lineage_edge') is not null then + delete from analysis_run_lineage_edge; + end if; + if to_regclass('public.analysis_run_reconstruction') is not null then + delete from analysis_run_reconstruction; + end if; delete from analysis_run_status_event; delete from analysis_run_scope; delete from analysis_run; delete from analysis_source_count; + if to_regclass('public.analysis_source_snapshot_member') is not null then + delete from analysis_source_snapshot_member; + end if; delete from analysis_source_snapshot; exception when others then @@ -139,6 +162,15 @@ begin enable trigger analysis_run_scope_mutation_reject; alter table analysis_run_status_event enable trigger analysis_run_status_event_delete_reject; + if to_regclass('public.analysis_source_snapshot_member') is not null then + execute 'alter table analysis_source_snapshot_member enable trigger user'; + end if; + if to_regclass('public.analysis_run_reconstruction') is not null then + execute 'alter table analysis_run_reconstruction enable trigger user'; + end if; + if to_regclass('public.analysis_run_lineage_edge') is not null then + execute 'alter table analysis_run_lineage_edge enable trigger user'; + end if; raise; end; @@ -148,6 +180,15 @@ begin enable trigger analysis_run_scope_mutation_reject; alter table analysis_run_status_event enable trigger analysis_run_status_event_delete_reject; + if to_regclass('public.analysis_source_snapshot_member') is not null then + execute 'alter table analysis_source_snapshot_member enable trigger user'; + end if; + if to_regclass('public.analysis_run_reconstruction') is not null then + execute 'alter table analysis_run_reconstruction enable trigger user'; + end if; + if to_regclass('public.analysis_run_lineage_edge') is not null then + execute 'alter table analysis_run_lineage_edge enable trigger user'; + end if; insert into analysis_run_retention_event ( purged_run_count, @@ -170,8 +211,10 @@ $$; comment on function purge_analysis_run_registry(text) is 'Empties immutable registry relations after an unrevoked role grant, ' 'analysis_run_retention_admin membership, and the documented approval ' - 'token; records one analysis_run_retention_event. Next action: export ' - 'that event, delete it, then roll back 0020 and 0018.'; + 'token; also empties analysis_run_lineage_edge, ' + 'analysis_run_reconstruction, and analysis_source_snapshot_member ' + 'when those ADR 0021 relations exist. Next action: export ' + 'analysis_run_retention_event, delete it, then roll back 0020 and 0018.'; revoke all on function purge_analysis_run_registry(text) from public; grant execute on function purge_analysis_run_registry(text) diff --git a/migrations/0023_analysis_run_outbox.sql b/migrations/0023_analysis_run_outbox.sql index 2aaef8c2..d766fbc5 100644 --- a/migrations/0023_analysis_run_outbox.sql +++ b/migrations/0023_analysis_run_outbox.sql @@ -155,15 +155,17 @@ begin end if; if to_regclass('public.analysis_run_lineage_edge') is not null then delete from analysis_run_lineage_edge; - delete from analysis_run_reconstruction; end if; - if to_regclass('public.analysis_source_snapshot_member') is not null then - delete from analysis_source_snapshot_member; + if to_regclass('public.analysis_run_reconstruction') is not null then + delete from analysis_run_reconstruction; end if; delete from analysis_run_status_event; delete from analysis_run_scope; delete from analysis_run; delete from analysis_source_count; + if to_regclass('public.analysis_source_snapshot_member') is not null then + delete from analysis_source_snapshot_member; + end if; delete from analysis_source_snapshot; exception when others then diff --git a/pyproject.toml b/pyproject.toml index 5cb37508..b32772d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.10.3" +version = "2.10.4" 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_registry_schema.py b/tests/test_analysis_run_registry_schema.py index df7f5690..d6843c78 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -319,6 +319,37 @@ def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> Non assert "analysis_run_retention_not_approved" in retention assert "analysis_run_retention_not_granted" in retention assert "analysis_run_retention_not_admin" in retention + assert "analysis_run_lineage_edge" in retention + assert "analysis_run_reconstruction" in retention + assert "analysis_source_snapshot_member" in retention + assert "to_regclass" in retention + assert retention.index("delete from analysis_run_lineage_edge") < ( + retention.index("delete from analysis_run_reconstruction") + ) + assert retention.index("delete from analysis_run_reconstruction") < ( + retention.index("delete from analysis_run_status_event") + ) + assert retention.index("delete from analysis_source_count") < ( + retention.index("delete from analysis_source_snapshot_member") + ) + assert retention.index("delete from analysis_source_snapshot_member") < ( + retention.index("delete from analysis_source_snapshot;") + ) + outbox_purge = ( + _ROOT / "migrations" / "0023_analysis_run_outbox.sql" + ).read_text(encoding="utf-8") + assert outbox_purge.index("delete from analysis_run_lineage_edge") < ( + outbox_purge.index("delete from analysis_run_reconstruction") + ) + assert outbox_purge.index("delete from analysis_run_reconstruction") < ( + outbox_purge.index("delete from analysis_run_status_event") + ) + assert outbox_purge.index("delete from analysis_source_count") < ( + outbox_purge.index("delete from analysis_source_snapshot_member") + ) + assert outbox_purge.index("delete from analysis_source_snapshot_member") < ( + outbox_purge.index("delete from analysis_source_snapshot;") + ) assert "analysis_run_retention_event_not_empty" in retention_rollback assert "jsonb" not in retention.casefold() for object_name in re.findall( @@ -925,6 +956,105 @@ def test_approved_retention_purge_empties_a_run_bearing_registry(registry_db) -> assert cursor.fetchone()[0] is None +def test_retention_purge_empties_optional_reconstruction_children( + registry_db, +) -> None: + """Grant plus admin empties ADR 0021 children despite delete-reject triggers.""" + + with registry_db.cursor() as cursor: + _insert_run_bearing_registry( + cursor, + digest="b" * 64, + idempotency_key="retention-purge-children", + ) + cursor.execute( + "select analysis_run_id, analysis_source_snapshot_id " + "from analysis_run" + ) + run_id, snapshot_id = cursor.fetchone() + cursor.execute( + """ + create table analysis_run_reconstruction ( + analysis_run_id uuid primary key + references analysis_run (analysis_run_id), + result_sha256 text not null, + edge_count integer not null, + reconstructed_at timestamptz not null + ); + create table analysis_run_lineage_edge ( + analysis_run_id uuid not null + references analysis_run_reconstruction (analysis_run_id), + child_post_id uuid not null, + parent_post_id uuid not null, + fused_score double precision not null, + primary key (analysis_run_id, child_post_id) + ); + create table analysis_source_snapshot_member ( + analysis_source_snapshot_id uuid not null + references analysis_source_snapshot + (analysis_source_snapshot_id), + source_post_id uuid not null, + primary key ( + analysis_source_snapshot_id, source_post_id + ) + ); + create function reject_reconstruction_child_delete() + returns trigger language plpgsql as $fn$ + begin + raise exception 'analysis_run_reconstruction_is_immutable'; + end + $fn$; + create trigger analysis_run_reconstruction_update_reject + before update or delete on analysis_run_reconstruction + for each row execute function reject_reconstruction_child_delete(); + create trigger analysis_run_lineage_edge_update_reject + before update or delete on analysis_run_lineage_edge + for each row execute function reject_reconstruction_child_delete(); + create trigger analysis_source_snapshot_member_update_reject + before update or delete on analysis_source_snapshot_member + for each row execute function reject_reconstruction_child_delete(); + """ + ) + cursor.execute( + "insert into analysis_run_reconstruction " + "(analysis_run_id, result_sha256, edge_count, reconstructed_at) " + "values (%s, %s, 1, now())", + (run_id, "c" * 64), + ) + cursor.execute( + "insert into analysis_run_lineage_edge " + "(analysis_run_id, child_post_id, parent_post_id, fused_score) " + "values (%s, %s, %s, 0.91)", + (run_id, str(uuid.uuid4()), str(uuid.uuid4())), + ) + cursor.execute( + "insert into analysis_source_snapshot_member " + "(analysis_source_snapshot_id, source_post_id) " + "values (%s, %s)", + (snapshot_id, str(uuid.uuid4())), + ) + with pytest.raises( + psycopg2.errors.RaiseException, + match="analysis_run_reconstruction_is_immutable", + ): + cursor.execute("delete from analysis_run_reconstruction") + _authorize_session_for_purge(cursor) + cursor.execute( + "select purge_analysis_run_registry(%s)", + ("approved-retention-purge",), + ) + cursor.execute("select count(*) from analysis_run") + assert cursor.fetchone()[0] == 0 + cursor.execute("select count(*) from analysis_source_snapshot") + assert cursor.fetchone()[0] == 0 + cursor.execute("select count(*) from analysis_run_reconstruction") + assert cursor.fetchone()[0] == 0 + cursor.execute("select count(*) from analysis_run_lineage_edge") + assert cursor.fetchone()[0] == 0 + cursor.execute("select count(*) from analysis_source_snapshot_member") + assert cursor.fetchone()[0] == 0 + + def test_retention_purge_requires_unrevoked_session_grant(registry_db) -> None: """Admin membership plus the published token cannot purge without a grant.""" From e49d38171580feaa965acd5fa75d0d8ca003a1c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:07:58 +0900 Subject: [PATCH 155/161] feat: customer-group tree and Searxng abbreviation cross-check (v2.11.0) Customer-group tree (Group/Company/Plant) plus fail-closed Searxng abbreviation cross-check against that tree. Synthetic Demo Corp seed only. ADR 0033, v2.11.0. Do not merge #74 onto main. --- ARCHITECTURE.md | 12 ++ .../2.11.0-customer-group-tree-searxng.md | 7 + CHANGELOG.md | 14 ++ ...breviation_tree_corroboration_ingestion.py | 149 +++++++++++++ backend/app/customer_group_tree_ingestion.py | 88 ++++++++ backend/app/main.py | 81 +++++++ backend/tests/test_api.py | 123 +++++++++++ docker/postgres-init/Dockerfile | 1 + ...r-group-tree-abbreviation-corroboration.md | 76 +++++++ frontend/package.json | 2 +- frontend/src/App.css | 6 + frontend/src/App.test.tsx | 125 ++++++++++- frontend/src/App.tsx | 203 ++++++++++++++++++ frontend/src/api.ts | 49 +++++ lineageweave/__init__.py | 2 +- .../abbreviation_tree_corroboration.py | 149 +++++++++++++ lineageweave/customer_group_tree.py | 139 ++++++++++++ migrations/0001_initial_schema.sql | 20 ++ .../0027_abbreviation_tree_corroboration.sql | 20 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 23 ++ tests/test_abbreviation_tree_corroboration.py | 130 +++++++++++ tests/test_customer_group_tree.py | 69 ++++++ tests/test_customer_group_tree_labels.py | 36 ++++ tests/test_documentation_hygiene.py | 5 + tests/test_schema.py | 1 + uv.lock | 2 +- 27 files changed, 1528 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.d/2.11.0-customer-group-tree-searxng.md create mode 100644 backend/app/abbreviation_tree_corroboration_ingestion.py create mode 100644 backend/app/customer_group_tree_ingestion.py create mode 100644 docs/adr/0033-customer-group-tree-abbreviation-corroboration.md create mode 100644 lineageweave/abbreviation_tree_corroboration.py create mode 100644 lineageweave/customer_group_tree.py create mode 100644 migrations/0027_abbreviation_tree_corroboration.sql create mode 100644 tests/test_abbreviation_tree_corroboration.py create mode 100644 tests/test_customer_group_tree.py create mode 100644 tests/test_customer_group_tree_labels.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5307a0d8..42fed5d8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -962,3 +962,15 @@ so it also covers the multi-entity opposite-order case a per-name lock would still deadlock on. Every already-cataloged entity still resolves through the unchanged, lock-free similarity-matching fast path; only the rare creation branch serializes. + +## Phase 14: customer-group tree plus Searxng abbreviation cross-check + +Operators navigate the authorized Group / Company / Plant catalog +(`GET /api/customer-group-tree`), not only the post-scoped affiliate +tree or the flat `/api/me` corp list. Abbreviations on a post are +cross-checked against that tree through the existing Searxng client +(`abbreviation_tree_corroboration`). A unique corroborated node binds; +a down, empty, or tied search stays unbound and does not invent a +parent or AUTO row. See +[ADR 0033](docs/adr/0033-customer-group-tree-abbreviation-corroboration.md). +This path does not reimplement ADR 0008 or ADR 0010. diff --git a/CHANGELOG.d/2.11.0-customer-group-tree-searxng.md b/CHANGELOG.d/2.11.0-customer-group-tree-searxng.md new file mode 100644 index 00000000..6ffd0f55 --- /dev/null +++ b/CHANGELOG.d/2.11.0-customer-group-tree-searxng.md @@ -0,0 +1,7 @@ +# 2.11.0 Customer-group tree and Searxng abbreviation cross-check + +Home shows the authorized Group / Company / Plant forest. A click opens +that Demo Corp node as the corporate-entity report grouping. Post +abbreviations are cross-checked against that tree through the existing +Searxng client. When Searxng is down, empty, or tied, the mention stays +unbound — no invented parent and no AUTO row (ADR 0033). diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c836ba8..b80b9092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.11.0] - 2026-08-17 + +### Added + +- Home now shows the authorized customer-group tree (Group / Company / + Plant) instead of only a flat corp list. After `make seed`, Demo + Analyst walks Demo Group → Demo Corp → Demo Plant; a click opens that + entity as the corporate-entity report grouping. The post-scoped + affiliate tree is unchanged (ADR 0033). +- Abbreviations on a post are cross-checked against that tree through + the existing Searxng client. A unique corroborated hit binds; a down, + empty, or tied search stays unbound and does not invent a parent or + AUTO row. Seeded `DC` on Demo Corp is synthetic Demo Corp only. + ## [2.10.4] - 2026-08-17 ### Fixed diff --git a/backend/app/abbreviation_tree_corroboration_ingestion.py b/backend/app/abbreviation_tree_corroboration_ingestion.py new file mode 100644 index 00000000..e8e7d6e9 --- /dev/null +++ b/backend/app/abbreviation_tree_corroboration_ingestion.py @@ -0,0 +1,149 @@ +"""Persist Searxng abbreviation matches against the authorized tree.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import asyncpg + +from lineageweave.abbreviation_tree_corroboration import ( + AbbreviationTreeMatch, + TreeEntityCandidate, + abbreviation_candidates, + corroborate_abbreviation_against_tree, +) +from lineageweave.customer_group_tree import CatalogEntityRow, authorized_catalog_ids +from lineageweave.relation_verification import RelationVerificationClient + + +async def collect_post_organization_names(conn: asyncpg.Connection, post_id: str) -> tuple[str, ...]: + """Organization strings already extracted onto this post. + + Keyman affiliations and classified counterparties are the mentions + operators can see. This path does not invent a name from post text. + """ + rows = await conn.fetch( + """ + select distinct name from ( + select pa.affiliated_organization_name as name + from post_person_mention ppm + join person_affiliation pa on pa.person_id = ppm.person_id + where ppm.post_id = $1 + union + select c.counterparty_entity_name as name + from post_counterparty_entity c + where c.post_id = $1 + ) mentioned + where name is not null and btrim(name) <> '' + order by name + """, + post_id, + ) + return tuple(row["name"] for row in rows) + + +async def load_authorized_tree_candidates( + conn: asyncpg.Connection, + affiliated_entity_ids: list[str], +) -> tuple[TreeEntityCandidate, ...]: + """Catalog nodes the account may corroborate an abbreviation against.""" + entity_rows = await conn.fetch( + """ + select corporate_entity_id, parent_entity_id, entity_name, entity_level_code + from corporate_entity + """ + ) + entities = tuple( + CatalogEntityRow( + entity_id=str(row["corporate_entity_id"]), + parent_entity_id=str(row["parent_entity_id"]) if row["parent_entity_id"] is not None else None, + entity_name=row["entity_name"], + entity_level_code=row["entity_level_code"], + ) + for row in entity_rows + ) + needed = authorized_catalog_ids(entities, affiliated_entity_ids) + return tuple( + TreeEntityCandidate(entity_id=row.entity_id, entity_name=row.entity_name) + for row in entities + if row.entity_id in needed + ) + + +async def persist_abbreviation_tree_match( + conn: asyncpg.Connection, + match: AbbreviationTreeMatch, +) -> None: + """Upsert one raw mention's tree-constrained Searxng outcome.""" + await conn.execute( + """ + insert into abbreviation_tree_corroboration + (raw_organization_name, corporate_entity_id, + verification_status_code, verification_evidence_url) + values ($1, $2, $3, $4) + on conflict (raw_organization_name) do update set + corporate_entity_id = excluded.corporate_entity_id, + verification_status_code = excluded.verification_status_code, + verification_evidence_url = excluded.verification_evidence_url, + corroborated_at = now() + """, + match.raw_organization_name, + match.corporate_entity_id, + match.verification_status_code, + match.verification_evidence_url, + ) + + +async def fetch_post_abbreviation_matches( + conn: asyncpg.Connection, + post_id: str, +) -> list[dict[str, Any]]: + """Cached tree matches for organization names already on this post.""" + names = await collect_post_organization_names(conn, post_id) + if not names: + return [] + rows = await conn.fetch( + """ + select raw_organization_name, corporate_entity_id, + verification_status_code, verification_evidence_url + from abbreviation_tree_corroboration + where raw_organization_name = any($1::text[]) + order by raw_organization_name + """, + list(names), + ) + return [ + { + "raw_organization_name": row["raw_organization_name"], + "corporate_entity_id": ( + str(row["corporate_entity_id"]) if row["corporate_entity_id"] is not None else None + ), + "verification_status_code": row["verification_status_code"], + "verification_evidence_url": row["verification_evidence_url"], + } + for row in rows + ] + + +async def corroborate_post_abbreviations( + conn: asyncpg.Connection, + verification_client: RelationVerificationClient, + post_id: str, + affiliated_entity_ids: list[str], +) -> list[AbbreviationTreeMatch]: + """Run Searxng against the authorized tree for this post's mentions.""" + names = await collect_post_organization_names(conn, post_id) + candidates = await load_authorized_tree_candidates(conn, affiliated_entity_ids) + to_check = abbreviation_candidates(names, candidates) + matches: list[AbbreviationTreeMatch] = [] + for raw_name in to_check: + match = await asyncio.to_thread( + corroborate_abbreviation_against_tree, + raw_name, + candidates, + verification_client, + ) + await persist_abbreviation_tree_match(conn, match) + matches.append(match) + return matches diff --git a/backend/app/customer_group_tree_ingestion.py b/backend/app/customer_group_tree_ingestion.py new file mode 100644 index 00000000..e4b79c76 --- /dev/null +++ b/backend/app/customer_group_tree_ingestion.py @@ -0,0 +1,88 @@ +"""Load the authorized customer-group forest from PostgreSQL.""" + +from __future__ import annotations + +from typing import Any + +import asyncpg + +from lineageweave.customer_group_tree import ( + CatalogEntityRow, + TreeAbbreviation, + build_customer_group_forest, +) +from lineageweave.relation_verification import STATUS_CORROBORATED + +from .knowledge_graph import labels_for_codes + + +async def fetch_customer_group_forest( + conn: asyncpg.Connection, + affiliated_entity_ids: list[str], +) -> list[dict[str, Any]]: + """Authorized Group / Company / Plant forest for one account.""" + entity_rows = await conn.fetch( + """ + select corporate_entity_id, parent_entity_id, entity_name, entity_level_code + from corporate_entity + """ + ) + entities = tuple( + CatalogEntityRow( + entity_id=str(row["corporate_entity_id"]), + parent_entity_id=str(row["parent_entity_id"]) if row["parent_entity_id"] is not None else None, + entity_name=row["entity_name"], + entity_level_code=row["entity_level_code"], + ) + for row in entity_rows + ) + alias_rows = await conn.fetch( + """ + select raw_organization_name, corporate_entity_id, + verification_status_code, verification_evidence_url + from abbreviation_tree_corroboration + where verification_status_code = $1 + and corporate_entity_id is not null + """, + STATUS_CORROBORATED, + ) + abbreviations = tuple( + ( + str(row["corporate_entity_id"]), + TreeAbbreviation( + raw_organization_name=row["raw_organization_name"], + verification_status_code=row["verification_status_code"], + verification_evidence_url=row["verification_evidence_url"], + ), + ) + for row in alias_rows + ) + forest = [ + node.to_dict() + for node in build_customer_group_forest(entities, affiliated_entity_ids, abbreviations) + ] + await _attach_lookup_labels(conn, forest) + return forest + + +def _collect_level_codes(nodes: list[dict[str, Any]]) -> list[str]: + """Every entity-level code in the forest.""" + codes: list[str] = [] + for node in nodes: + if node.get("entity_level_code"): + codes.append(node["entity_level_code"]) + codes.extend(_collect_level_codes(node.get("children", []))) + return codes + + +def _apply_lookup_labels(nodes: list[dict[str, Any]], labels: dict[str, str]) -> None: + """Write display labels onto the JSON forest, falling back to the code.""" + for node in nodes: + level = node.get("entity_level_code") + node["entity_level_label"] = labels.get(level, level) if level else None + _apply_lookup_labels(node.get("children", []), labels) + + +async def _attach_lookup_labels(conn: asyncpg.Connection, forest: list[dict[str, Any]]) -> None: + """Hydrate ``entity_level_label`` from lookup rows.""" + _apply_lookup_labels(forest, await labels_for_codes(conn, _collect_level_codes(forest))) diff --git a/backend/app/main.py b/backend/app/main.py index daaded5b..a3ac770f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -89,7 +89,12 @@ ticket_created_summary, ticket_status_changed_summary, ) +from backend.app.abbreviation_tree_corroboration_ingestion import ( + corroborate_post_abbreviations, + fetch_post_abbreviation_matches, +) from backend.app.affiliate_tree_ingestion import fetch_affiliate_forest, fetch_voc_evidence +from backend.app.customer_group_tree_ingestion import fetch_customer_group_forest from backend.app.auth import CurrentAccount, get_current_account from backend.app.config import load_settings from backend.app.db import create_pool, get_pool @@ -363,6 +368,24 @@ async def read_me( } +@app.get("/api/customer-group-tree") +async def read_customer_group_tree( + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Authorized Group / Company / Plant forest this token may navigate. + + Affiliated corps pull in ancestors and descendants. A catalog row + the account does not touch is omitted -- a missing affiliation is + not a guessed parent. Corroborated abbreviations attach as + alternative labels; Searxng is not called on this read. + """ + _require_post_read(account) + async with pool.acquire() as conn: + trees = await fetch_customer_group_forest(conn, list(account.corporate_entity_ids)) + return {"trees": trees} + + @app.get("/api/lineage") async def read_lineage_graph( account: CurrentAccount = Depends(get_current_account), @@ -609,6 +632,64 @@ async def read_post_affiliate_tree( return {"post_id": str(post["post_id"]), "trees": trees} +@app.get("/api/posts/{post_id}/abbreviation-tree-matches") +async def read_post_abbreviation_tree_matches( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Cached Searxng tree matches for organization names on this post. + + Does not call Searxng. A missing cache row means the mention has + not been cross-checked yet, not that a parent was invented. + """ + post = await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + matches = await fetch_post_abbreviation_matches(conn, post_id) + return {"post_id": str(post["post_id"]), "matches": matches} + + +@app.post("/api/posts/{post_id}/corroborate-abbreviations") +async def corroborate_post_abbreviation_tree( + post_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Cross-check this post's abbreviations against the customer-group tree. + + Reuses the existing Searxng client. Fail-closed: unavailable search + is 503, not an invented parent or AUTO row. A tied or empty result + stays unbound. post_admin only -- a real external-search write. + """ + _require_post_admin(account) + post = await _load_visible_post(post_id, account, pool) + client = _relation_verification_client() + if not client.available: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Abbreviation tree corroboration is unavailable: set SEARXNG_BASE_URL", + ) + async with pool.acquire() as conn: + matches = await corroborate_post_abbreviations( + conn, + client, + post_id, + list(account.corporate_entity_ids), + ) + return { + "post_id": str(post["post_id"]), + "matches": [ + { + "raw_organization_name": match.raw_organization_name, + "corporate_entity_id": match.corporate_entity_id, + "verification_status_code": match.verification_status_code, + "verification_evidence_url": match.verification_evidence_url, + } + for match in matches + ], + } + + @app.get("/api/posts/{post_id}/voc-evidence") async def read_post_voc_evidence( post_id: str, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 71b4e46d..701168bd 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -184,6 +184,12 @@ def seeded_db(demo_analyst_token): (own_group_id,), ) own_corp_id = cur.fetchone()[0] + cur.execute( + "insert into corporate_entity (parent_entity_id, corporate_entity_code, entity_name, entity_level_code) " + "values (%s, 'TEST-PLANT', 'Test Plant', 'plant') returning corporate_entity_id", + (own_corp_id,), + ) + own_plant_id = cur.fetchone()[0] cur.execute( "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " "values ('OTHER-CORP', 'Other Corp', 'group') returning corporate_entity_id" @@ -454,6 +460,7 @@ def _insert_post( "public_post_id": public_post_id, "own_group_id": str(own_group_id), "own_corp_id": str(own_corp_id), + "own_plant_id": str(own_plant_id), "other_corp_id": str(other_corp_id), "own_private_post_id": own_private_post_id, "late_own_private_post_id": late_own_private_post_id, @@ -1579,6 +1586,122 @@ def test_affiliate_tree_walks_ancestors_and_keeps_unresolved_orgs(client, demo_a assert all(person["person_name"] == "Priya Nair" for node in unresolved for person in node["people"]) +def test_customer_group_tree_walks_authorized_ancestors_and_descendants( + client, demo_analyst_token, seeded_db +) -> None: + """Operators navigate Group → Company → Plant, not a flat corp list.""" + response = client.get( + "/api/customer-group-tree", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200, response.text + trees = response.json()["trees"] + assert [node["entity_name"] for node in trees] == ["Test Group"] + assert trees[0]["entity_id"] == seeded_db["own_group_id"] + assert trees[0]["entity_level_label"] == "Group" + children = trees[0]["children"] + assert [child["entity_name"] for child in children] == ["Test Corp"] + assert children[0]["entity_id"] == seeded_db["own_corp_id"] + assert children[0]["entity_level_label"] == "Company" + plants = children[0]["children"] + assert [plant["entity_name"] for plant in plants] == ["Test Plant"] + assert plants[0]["entity_id"] == seeded_db["own_plant_id"] + assert plants[0]["entity_level_label"] == "Plant" + assert "Other Corp" not in str(trees) + + +def test_corroborate_abbreviations_requires_post_admin( + client, demo_analyst_token, seeded_db +) -> None: + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/corroborate-abbreviations", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + +def test_corroborate_abbreviations_is_unavailable_without_searxng( + client, demo_analyst_token, seeded_db +) -> None: + _grant_post_admin(seeded_db["dsn"]) + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/corroborate-abbreviations", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 503 + assert "SEARXNG_BASE_URL" in response.json()["detail"] + + +def test_corroborate_abbreviations_binds_a_unique_tree_node( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Searxng must corroborate TC against Test Corp; a miss invents nothing.""" + from lineageweave.relation_verification import STATUS_CORROBORATED, RelationVerificationResult + + _grant_post_admin(seeded_db["dsn"]) + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into person_affiliation (person_id, affiliated_organization_name) " + "values (%s, 'TC')", + (seeded_db["our_person_id"],), + ) + finally: + admin_conn.close() + + class _FakeVerificationClient: + available = True + + def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: + if organization_name == "Test Corp" and relationship_label == "TC": + return RelationVerificationResult( + STATUS_CORROBORATED, "https://example.test/test-corp-tc" + ) + return RelationVerificationResult("verify_uncorroborated", None) + + monkeypatch.setattr("backend.app.main._relation_verification_client", lambda: _FakeVerificationClient()) + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/corroborate-abbreviations", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200, response.text + matches = {row["raw_organization_name"]: row for row in response.json()["matches"]} + assert matches["TC"]["corporate_entity_id"] == seeded_db["own_corp_id"] + assert matches["TC"]["verification_status_code"] == "verify_corroborated" + assert "Test Corp" not in matches + for unresolved_name in ("Northridge Grid", "Northridge Holdings"): + if unresolved_name in matches: + assert matches[unresolved_name]["corporate_entity_id"] is None + assert matches[unresolved_name]["verification_status_code"] == "verify_uncorroborated" + + cached = client.get( + f"/api/posts/{seeded_db['own_private_post_id']}/abbreviation-tree-matches", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert cached.status_code == 200 + cached_matches = {row["raw_organization_name"]: row for row in cached.json()["matches"]} + assert cached_matches["TC"]["corporate_entity_id"] == seeded_db["own_corp_id"] + + tree = client.get( + "/api/customer-group-tree", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + company = tree.json()["trees"][0]["children"][0] + assert [alias["raw_organization_name"] for alias in company["abbreviations"]] == ["TC"] + + +def test_other_corp_private_abbreviation_cross_check_is_forbidden( + client, demo_analyst_token, seeded_db +) -> None: + listed = client.get( + f"/api/posts/{seeded_db['other_private_post_id']}/abbreviation-tree-matches", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert listed.status_code == 403 + + def test_other_corp_private_affiliate_tree_is_forbidden(client, demo_analyst_token, seeded_db) -> None: response = client.get( f"/api/posts/{seeded_db['other_private_post_id']}/affiliate-tree", diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index 9bca1b16..f37a17e4 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -32,6 +32,7 @@ COPY migrations/0023_analysis_run_outbox.sql /docker-entrypoint-initdb.d/24-anal COPY migrations/0024_source_post_revision.sql /docker-entrypoint-initdb.d/25-source-post-revision.sql COPY migrations/0025_role_person_catalog_identity.sql /docker-entrypoint-initdb.d/26-role-person-catalog-identity.sql COPY migrations/0026_report_leftover_pair.sql /docker-entrypoint-initdb.d/27-report-leftover-pair.sql +COPY migrations/0027_abbreviation_tree_corroboration.sql /docker-entrypoint-initdb.d/28-abbreviation-tree-corroboration.sql # Official image already drops to this account at runtime; declare it so # the Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER postgres diff --git a/docs/adr/0033-customer-group-tree-abbreviation-corroboration.md b/docs/adr/0033-customer-group-tree-abbreviation-corroboration.md new file mode 100644 index 00000000..8edef686 --- /dev/null +++ b/docs/adr/0033-customer-group-tree-abbreviation-corroboration.md @@ -0,0 +1,76 @@ +# ADR 0033 — Operators navigate a customer-group tree; abbreviations are Searxng-checked against that tree + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-17 +**Depends on:** ADR 0004 SKOS Group / Company / Plant; ADR 0005 / 0008 Searxng verification; ADR 0010 hierarchy auto-creation (not redone here) + +## Context + +Live #74 already resolves abbreviated names (ADR 0008), infers a +Group / Company / Plant placement (ADR 0010), and renders a +**post-scoped** affiliate tree of Keymen on one record. Operators still +meet a flat corp list on home (`GET /api/me`) and have no catalog-wide +navigator. Abbreviations are paired to an LLM-proposed name, not +cross-checked against the tree the buyer can see. + +A missing Searxng channel must not invent a parent or auto-create a +catalog row from a guess (Thorne, Vlachos, Christodoulopoulos, & Mittal, +2018; Fellegi & Sunter, 1969). Public git stays synthetic Demo Corp +only (ADR 0001). + +## Decision + +1. **Customer-group tree.** `GET /api/customer-group-tree` returns the + authorized forest: affiliated `corporate_entity` rows plus ancestors + and descendants, using the existing `corporate_entity_level` codes + (`group`, `company`, `plant`). A catalog row the account does not + touch is omitted. The React home panel is a nested list; a click + opens that entity as the `corporate_entity` report grouping. This is + not the post affiliate tree and does not embed raw HTML. +2. **Abbreviation cross-check against that tree.** + `lineageweave.abbreviation_tree_corroboration` reuses + `SearxngRelationVerificationClient`. It does not call an LLM and + does not insert a `corporate_entity` row. A raw mention is queried + against each authorized tree node. Exactly one corroborated node + binds. Zero hits or a tie stay unbound. An unavailable or failed + search is not recorded as uncorroborated: the write route returns + 503 when `SEARXNG_BASE_URL` is unset, and a raised search error + propagates. Persistence is + `abbreviation_tree_corroboration` (3NF; keyed by + `raw_organization_name`). +3. **Seed.** Demo Group → Demo Corp → Demo Plant, plus a synthetic + `DC` → Demo Corp corroborated fixture at + `https://example.test/demo-corp-dc`. + +ADR 0008 and ADR 0010 stay the LLM-expansion and AUTO-creation paths. +This slice does not replace them. + +## Consequences + +- Operators can walk the customer-group hierarchy without opening a + post. +- An abbreviation that Searxng cannot uniquely place on the tree stays + text. No parent is invented. No AUTO row is created from this path. +- Existing volumes apply `0027_abbreviation_tree_corroboration.sql`. + Fresh `0001_initial_schema.sql` already contains the table. + +## References — APA 7th + +Bhattacharya, I., & Getoor, L. (2007). Collective entity resolution in +relational data. *ACM Transactions on Knowledge Discovery from Data, +1*(1), Article 5. https://doi.org/10.1145/1217299.1217304 + +Fellegi, I. P., & Sunter, A. B. (1969). A theory for record linkage. +*Journal of the American Statistical Association, 64*(328), 1183–1210. +https://doi.org/10.1080/01621459.1969.10501049 + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge +organization system reference*. World Wide Web Consortium. +https://www.w3.org/TR/skos-reference/ + +Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). +FEVER: A large-scale dataset for fact extraction and VERification. In +*Proceedings of the 2018 Conference of the North American Chapter of +the Association for Computational Linguistics: Human Language +Technologies* (pp. 809–819). Association for Computational Linguistics. +https://doi.org/10.18653/v1/N18-1074 diff --git a/frontend/package.json b/frontend/package.json index f191172d..b63549a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.10.4", + "version": "2.11.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 0ca19758..3b312b7a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -439,6 +439,12 @@ font-size: 0.8rem; } +.customer-group-abbreviations { + list-style: none; + padding-left: 1.25rem; + margin: 0.15rem 0 0; +} + .voc-excerpt-list { list-style: none; padding: 0; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index b759fccb..f54faee5 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -765,6 +765,84 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/customer-group-tree")) { + return Promise.resolve( + jsonResponse({ + trees: [ + { + entity_id: "group-1", + entity_name: "Demo Group", + entity_level_code: "group", + entity_level_label: "Group", + abbreviations: [], + children: [ + { + entity_id: "corp-1", + entity_name: "Demo Corp", + entity_level_code: "company", + entity_level_label: "Company", + abbreviations: [ + { + raw_organization_name: "DC", + verification_status_code: "verify_corroborated", + verification_evidence_url: "https://example.test/demo-corp-dc", + }, + ], + children: [ + { + entity_id: "plant-1", + entity_name: "Demo Plant", + entity_level_code: "plant", + entity_level_label: "Plant", + abbreviations: [], + children: [], + }, + ], + }, + ], + }, + ], + }), + ); + } + if (url.endsWith("/api/posts/post-1/abbreviation-tree-matches")) { + return Promise.resolve( + jsonResponse({ + matches: [ + { + raw_organization_name: "DC", + corporate_entity_id: "corp-1", + verification_status_code: "verify_corroborated", + verification_evidence_url: "https://example.test/demo-corp-dc", + }, + ], + }), + ); + } + if (url.endsWith("/api/posts/post-1/corroborate-abbreviations") && method === "POST") { + if (options?.searchUnavailable) { + return Promise.resolve( + new Response( + JSON.stringify({ + detail: "Abbreviation tree corroboration is unavailable: set SEARXNG_BASE_URL", + }), + { status: 503, headers: { "Content-Type": "application/json" } }, + ), + ); + } + return Promise.resolve( + jsonResponse({ + matches: [ + { + raw_organization_name: "DC", + corporate_entity_id: "corp-1", + verification_status_code: "verify_corroborated", + verification_evidence_url: "https://example.test/demo-corp-dc", + }, + ], + }), + ); + } if (url.endsWith("/api/rankings")) { const rankings = options?.rankings ?? { status: "unavailable" as const, @@ -1733,11 +1811,13 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); - await waitFor(() => expect(screen.getByText("Demo Group")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByRole("button", { name: "Affiliate org: Demo Group" })).toBeInTheDocument(), + ); expect(screen.getByRole("button", { name: "Affiliate org: Demo Corp" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Counterparty org: Demo Corp" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Keyman affiliation: Demo Corp" })).toBeInTheDocument(); - expect(screen.getByText("(Company)")).toBeInTheDocument(); + expect(screen.getAllByText("(Company)").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText(/Ada West \(Our side\)/).length).toBeGreaterThanOrEqual(1); expect(screen.getByText("Account manager")).toBeInTheDocument(); expect(screen.queryByText(/our_side/)).not.toBeInTheDocument(); @@ -2074,6 +2154,47 @@ describe("App, authenticated", () => { expect(screen.queryByText("Pricing renegotiation: revised quote sent")).not.toBeInTheDocument(); }); + it("lets an operator navigate the customer group tree instead of a flat corp list", async () => { + stubBackend(); + render(); + + const tree = await screen.findByRole("list", { name: "Customer group hierarchy" }); + expect(within(tree).getByRole("button", { name: "Open customer group: Demo Group" })).toBeInTheDocument(); + expect(within(tree).getByRole("button", { name: "Open customer group: Demo Corp" })).toBeInTheDocument(); + expect(within(tree).getByRole("button", { name: "Open customer group: Demo Plant" })).toBeInTheDocument(); + expect(within(tree).getByText("DC")).toBeInTheDocument(); + expect(within(tree).getByText(/corroborated/)).toBeInTheDocument(); + + await userEvent.click(within(tree).getByRole("button", { name: "Open customer group: Demo Corp" })); + expect(screen.getByLabelText("Report grouping")).toHaveValue("corporate_entity"); + expect( + screen.getByText( + "Demo Corp is the opened grouping. Read its mean θ and member posts below, then open a post.", + ), + ).toBeInTheDocument(); + }); + + it("shows a post abbreviation cross-check and fail-closes when Searxng is down", async () => { + stubBackend({ admin: true, searchUnavailable: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + const popup = await waitFor(() => { + const panel = document.querySelector(".popup-panel"); + expect(panel).not.toBeNull(); + return panel as HTMLElement; + }); + expect(within(popup).getByRole("heading", { name: "Abbreviation cross-check" })).toBeInTheDocument(); + expect(within(popup).getByText("DC")).toBeInTheDocument(); + + await userEvent.click( + within(popup).getByRole("button", { name: "Cross-check against customer group tree" }), + ); + expect( + await within(popup).findByText("Verification unavailable (search is not configured)."), + ).toBeInTheDocument(); + }); + it("opens an accepted ranking hit without inventing a fused score", async () => { stubBackend({ rankings: { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7ddd739f..93b45a2c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { useAuth } from "react-oidc-context"; import { askPostChat, BackendError, + corroboratePostAbbreviations, createAnalysisRun, startAnalysisRun, createPostTicket, @@ -12,10 +13,12 @@ import { fetchAnalysisRun, fetchAnalysisRuns, fetchCalendar, + fetchCustomerGroupTree, fetchLineageGraph, fetchMe, fetchPost, fetchPostActivity, + fetchPostAbbreviationTreeMatches, fetchPostChat, fetchPostAffiliateTree, fetchPostCounterparties, @@ -37,6 +40,7 @@ import { rebuildPeriodReports, updateTicketStatus, verifyPostRelations, + type AbbreviationTreeMatch, type ActivityEvent, type AffiliateNode, type AnalysisRun, @@ -45,6 +49,7 @@ import { type ChatExchange, type CorporateEntityRef, type Counterparty, + type CustomerGroupNode, type EvaluationResponse, type IssueTicket, type LineageGraph, @@ -60,6 +65,7 @@ import { type RankingList, type RelatedNode, type RelatedNodeType, + type VerificationStatusCode, type VocEvidence, } from "./api"; import { CitationChip } from "./components/CitationChip"; @@ -86,6 +92,32 @@ function searchUnavailableMessage(err: unknown): string { return String(err); } +function abbreviationStatusLabel(code: VerificationStatusCode): string { + switch (code) { + case "verify_corroborated": + return "corroborated"; + case "verify_uncorroborated": + return "uncorroborated"; + case "verify_pending": + return "pending"; + default: { + const _exhaustive: never = code; + return _exhaustive; + } + } +} + +function parseVerificationStatus(code: string): VerificationStatusCode { + switch (code) { + case "verify_corroborated": + case "verify_uncorroborated": + case "verify_pending": + return code; + default: + return "verify_pending"; + } +} + const CRITERION_SHORT_LABEL: Record = { general_sentiment_positive: "constructive", general_sentiment_negative: "negative", @@ -1171,6 +1203,79 @@ function CounterpartyPanel({ ); } +function AbbreviationCrossCheckPanel({ + postId, + accessToken, + canExtract, +}: { + postId: string; + accessToken: string; + canExtract: boolean; +}) { + const [matches, setMatches] = useState(null); + const [checking, setChecking] = useState(false); + const [error, setError] = useState(null); + const [searchOff, setSearchOff] = useState(false); + + useEffect(() => { + setMatches(null); + setError(null); + setSearchOff(false); + fetchPostAbbreviationTreeMatches(accessToken, postId) + .then((payload) => setMatches(payload.matches)) + .catch(() => setMatches([])); + }, [accessToken, postId]); + + async function handleCorroborate() { + setChecking(true); + setError(null); + try { + const payload = await corroboratePostAbbreviations(accessToken, postId); + setMatches(payload.matches); + } catch (err) { + setError(searchUnavailableMessage(err)); + if (err instanceof BackendError && err.status === 503) { + setSearchOff(true); + } + } finally { + setChecking(false); + } + } + + return ( +
    +
    +

    Abbreviation cross-check

    + {canExtract && !searchOff && ( + + )} +
    + {error &&

    {error}

    } + {matches === null ? ( +

    Loading abbreviation cross-check...

    + ) : matches.length === 0 ? ( +

    + No abbreviations cross-checked against the customer group tree yet. +

    + ) : ( +
      + {matches.map((match) => ( +
    • + {match.raw_organization_name} + + {" "} + ({abbreviationStatusLabel(parseVerificationStatus(match.verification_status_code))}) + +
    • + ))} +
    + )} +
    + ); +} + const TICKET_STATUS_OPTIONS = [ { code: "open", fallback: "Open" }, { code: "in_progress", fallback: "In progress" }, @@ -1676,6 +1781,12 @@ function PostDetailPopup({ /> )} + +

    Affiliate tree

    {affiliateTrees === null ? ( @@ -2411,6 +2522,91 @@ function AnalysisRunsPanel({ ); } +function CustomerGroupTreeNode({ + node, + onSelectEntity, +}: { + node: CustomerGroupNode; + onSelectEntity: (entityId: string, entityName: string) => void; +}) { + return ( +
  • + + {(node.entity_level_label || node.entity_level_code) && ( + ({node.entity_level_label ?? node.entity_level_code}) + )} + {node.abbreviations.length > 0 && ( +
      + {node.abbreviations.map((alias) => ( +
    • + {alias.raw_organization_name} + + {" "} + ({abbreviationStatusLabel(parseVerificationStatus(alias.verification_status_code))}) + +
    • + ))} +
    + )} + {node.children.length > 0 && ( +
      + {node.children.map((child) => ( + + ))} +
    + )} +
  • + ); +} + +function CustomerGroupTreePanel({ + accessToken, + onSelectEntity, +}: { + accessToken: string; + onSelectEntity: (entityId: string, entityName: string) => void; +}) { + const [trees, setTrees] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + setError(null); + fetchCustomerGroupTree(accessToken) + .then((payload) => setTrees(payload.trees)) + .catch((err) => setError(String(err))); + }, [accessToken]); + + return ( +
    +
    +

    Customer group tree

    +
    + {error &&

    {error}

    } + {trees === null && !error &&

    Loading customer group tree...

    } + {trees && trees.length === 0 && ( +

    No customer-group hierarchy for this account.

    + )} + {trees && trees.length > 0 && ( +
      + {trees.map((node) => ( + + ))} +
    + )} +
    + ); +} + function RankingsPanel({ accessToken, onSelectPost, @@ -2985,6 +3181,13 @@ function PostList({ accessToken }: { accessToken: string }) { return ( <> + { + selectReportGrouping("corporate_entity"); + openComparedGrouping(entityId, entityName); + }} + /> { + return backendFetch("/api/customer-group-tree", accessToken); +} + +export function fetchPostAbbreviationTreeMatches( + accessToken: string, + postId: string, +): Promise<{ matches: AbbreviationTreeMatch[] }> { + return backendFetch(`/api/posts/${postId}/abbreviation-tree-matches`, accessToken); +} + +export function corroboratePostAbbreviations( + accessToken: string, + postId: string, +): Promise<{ matches: AbbreviationTreeMatch[] }> { + return backendFetch(`/api/posts/${postId}/corroborate-abbreviations`, accessToken, { + method: "POST", + }); +} + export function fetchPostVocEvidence(accessToken: string, postId: string): Promise { return backendFetch(`/api/posts/${postId}/voc-evidence`, accessToken); } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 7e9c6fa7..cd000d25 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.10.4" +__version__ = "2.11.0" diff --git a/lineageweave/abbreviation_tree_corroboration.py b/lineageweave/abbreviation_tree_corroboration.py new file mode 100644 index 00000000..4b11d47d --- /dev/null +++ b/lineageweave/abbreviation_tree_corroboration.py @@ -0,0 +1,149 @@ +"""Cross-check a post abbreviation against the customer-group tree. + +ADR 0008 asks an LLM to invent a canonical name, then Searxng-verifies +that pairing. This module does not invent a name and does not create a +``corporate_entity`` row. It only asks the existing Searxng client +whether a raw mention corroborates against a node already on the +authorized tree. + +Fail-closed (Thorne, Vlachos, Christodoulopoulos, & Mittal, 2018; +Fellegi & Sunter, 1969): + +- Searxng unavailable or a search that raises: no parent, no AUTO row. +- Zero corroborated nodes: unbound. +- Two or more corroborated nodes: unbound (a tie is not a first-win). +- Exactly one corroborated node: bind that catalog id. + +A mention that already uniquely equals a catalog name is not an +abbreviation -- callers skip it so Searxng is reserved for the +altLabel case (Miles & Bechhofer, 2009). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .corporate_hierarchy_resolution import normalize_organization_name +from .relation_verification import ( + STATUS_CORROBORATED, + STATUS_PENDING, + STATUS_UNCORROBORATED, + RelationVerificationClient, + RelationVerificationResult, +) + + +@dataclass(frozen=True) +class TreeEntityCandidate: + """One authorized catalog node Searxng may corroborate against.""" + + entity_id: str + entity_name: str + + +@dataclass(frozen=True) +class AbbreviationTreeMatch: + """One raw mention's tree-constrained Searxng outcome. + + Attributes: + raw_organization_name: the mention as written on the post. + corporate_entity_id: the unique corroborated catalog id, or + ``None`` when the channel is pending, empty, or tied. + verification_status_code: ``relation_verification_status`` code. + verification_evidence_url: the unique hit's evidence URL, or + ``None`` when unbound. + """ + + raw_organization_name: str + corporate_entity_id: str | None + verification_status_code: str + verification_evidence_url: str | None + + +def exact_catalog_matches( + raw_name: str, + candidates: tuple[TreeEntityCandidate, ...] | list[TreeEntityCandidate], +) -> tuple[TreeEntityCandidate, ...]: + """Catalog nodes whose normalized name equals the raw mention.""" + normalized = normalize_organization_name(raw_name) + if not normalized: + return () + return tuple( + candidate + for candidate in candidates + if normalize_organization_name(candidate.entity_name) == normalized + ) + + +def abbreviation_candidates( + raw_names: tuple[str, ...] | list[str], + tree_nodes: tuple[TreeEntityCandidate, ...] | list[TreeEntityCandidate], +) -> tuple[str, ...]: + """Mentions that are not already a unique catalog name. + + Empty strings are dropped. A unique exact catalog match is already + bound by identity and is not sent to Searxng. A tied exact match + stays in the list so Searxng can still fail closed rather than + first-winning a homonym. + """ + seen: set[str] = set() + kept: list[str] = [] + for raw_name in raw_names: + stripped = raw_name.strip() + if not stripped or stripped in seen: + continue + seen.add(stripped) + matches = exact_catalog_matches(stripped, tree_nodes) + if len(matches) == 1: + continue + kept.append(stripped) + return tuple(kept) + + +def corroborate_abbreviation_against_tree( + raw_name: str, + candidates: tuple[TreeEntityCandidate, ...] | list[TreeEntityCandidate], + verification_client: RelationVerificationClient, +) -> AbbreviationTreeMatch: + """Bind ``raw_name`` to a unique tree node, or leave it unbound. + + A failed search must raise -- that is not + ``STATUS_UNCORROBORATED``. An unavailable client returns + ``STATUS_PENDING`` with no catalog id. + """ + stripped = raw_name.strip() + if not stripped: + return AbbreviationTreeMatch( + raw_organization_name=raw_name, + corporate_entity_id=None, + verification_status_code=STATUS_UNCORROBORATED, + verification_evidence_url=None, + ) + if not verification_client.available: + return AbbreviationTreeMatch( + raw_organization_name=stripped, + corporate_entity_id=None, + verification_status_code=STATUS_PENDING, + verification_evidence_url=None, + ) + + corroborated: list[tuple[TreeEntityCandidate, RelationVerificationResult]] = [] + for candidate in candidates: + result = verification_client.verify(candidate.entity_name, stripped) + if result.status_code == STATUS_CORROBORATED: + corroborated.append((candidate, result)) + + if len(corroborated) == 1: + candidate, result = corroborated[0] + return AbbreviationTreeMatch( + raw_organization_name=stripped, + corporate_entity_id=candidate.entity_id, + verification_status_code=STATUS_CORROBORATED, + verification_evidence_url=result.evidence_url, + ) + return AbbreviationTreeMatch( + raw_organization_name=stripped, + corporate_entity_id=None, + verification_status_code=STATUS_UNCORROBORATED, + verification_evidence_url=None, + ) diff --git a/lineageweave/customer_group_tree.py b/lineageweave/customer_group_tree.py new file mode 100644 index 00000000..a6c921c5 --- /dev/null +++ b/lineageweave/customer_group_tree.py @@ -0,0 +1,139 @@ +"""Build the authorized customer-group forest operators navigate. + +The post popup's affiliate tree is the ancestor forest of Keymen on one +record. This module is the complementary catalog view: every +``corporate_entity`` the account is affiliated with, plus ancestors and +descendants, using the existing Group / Company / Plant codes +(``corporate_entity_level``). A sibling the account is not affiliated +with stays omitted -- affiliation is not a guessed parent +(Bhattacharya & Getoor, 2007). + +Corroborated abbreviations attach as SKOS alternative labels +(Miles & Bechhofer, 2009). Uncorroborated rows never appear here. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CatalogEntityRow: + """One ``corporate_entity`` row the forest builder needs.""" + + entity_id: str + parent_entity_id: str | None + entity_name: str + entity_level_code: str + + +@dataclass(frozen=True) +class TreeAbbreviation: + """One Searxng-corroborated alternative label on a catalog node.""" + + raw_organization_name: str + verification_status_code: str + verification_evidence_url: str | None + + +@dataclass(frozen=True) +class CustomerGroupNode: + """One Group / Company / Plant node in the authorized forest.""" + + entity_id: str + entity_name: str + entity_level_code: str + children: tuple["CustomerGroupNode", ...] + abbreviations: tuple[TreeAbbreviation, ...] + + def to_dict(self) -> dict: + """JSON shape the product API and React navigator consume.""" + return { + "entity_id": self.entity_id, + "entity_name": self.entity_name, + "entity_level_code": self.entity_level_code, + "abbreviations": [ + { + "raw_organization_name": alias.raw_organization_name, + "verification_status_code": alias.verification_status_code, + "verification_evidence_url": alias.verification_evidence_url, + } + for alias in self.abbreviations + ], + "children": [child.to_dict() for child in self.children], + } + + +def authorized_catalog_ids( + entities: tuple[CatalogEntityRow, ...] | list[CatalogEntityRow], + affiliated_ids: tuple[str, ...] | list[str] | set[str], +) -> set[str]: + """Affiliated rows plus every ancestor and descendant. + + An affiliated company therefore surfaces its group parent and plant + children. A catalog row the account does not touch stays out. + """ + entity_by_id = {row.entity_id: row for row in entities} + children_of: dict[str | None, list[str]] = {} + for row in entities: + children_of.setdefault(row.parent_entity_id, []).append(row.entity_id) + + needed: set[str] = set() + for affiliated_id in affiliated_ids: + if affiliated_id not in entity_by_id: + continue + current: str | None = affiliated_id + while current and current not in needed: + row = entity_by_id.get(current) + if row is None: + break + needed.add(current) + current = row.parent_entity_id + stack = [affiliated_id] + while stack: + entity_id = stack.pop() + for child_id in children_of.get(entity_id, ()): + if child_id not in needed: + needed.add(child_id) + stack.append(child_id) + return needed + + +def build_customer_group_forest( + entities: tuple[CatalogEntityRow, ...] | list[CatalogEntityRow], + affiliated_ids: tuple[str, ...] | list[str] | set[str], + abbreviations: tuple[tuple[str, TreeAbbreviation], ...] | list[tuple[str, TreeAbbreviation]] = (), +) -> tuple[CustomerGroupNode, ...]: + """Authorized Group / Company / Plant forest for one account. + + ``abbreviations`` pairs a catalog entity id with a corroborated + alternative label. Labels for an omitted entity are dropped. + """ + entity_by_id = {row.entity_id: row for row in entities} + needed = authorized_catalog_ids(entities, affiliated_ids) + aliases_by_entity: dict[str, list[TreeAbbreviation]] = {} + for entity_id, alias in abbreviations: + if entity_id in needed: + aliases_by_entity.setdefault(entity_id, []).append(alias) + for alias_list in aliases_by_entity.values(): + alias_list.sort(key=lambda alias: alias.raw_organization_name) + + children_of: dict[str | None, list[str]] = {} + for entity_id in needed: + parent_id = entity_by_id[entity_id].parent_entity_id + root_parent = parent_id if parent_id in needed else None + children_of.setdefault(root_parent, []).append(entity_id) + for child_ids in children_of.values(): + child_ids.sort(key=lambda entity_id: (entity_by_id[entity_id].entity_name, entity_id)) + + def _build(entity_id: str) -> CustomerGroupNode: + row = entity_by_id[entity_id] + return CustomerGroupNode( + entity_id=row.entity_id, + entity_name=row.entity_name, + entity_level_code=row.entity_level_code, + abbreviations=tuple(aliases_by_entity.get(entity_id, ())), + children=tuple(_build(child_id) for child_id in children_of.get(entity_id, ())), + ) + + return tuple(_build(entity_id) for entity_id in children_of.get(None, ())) diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 24aa1f40..626d06e0 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -621,4 +621,24 @@ create table organization_name_resolution ( comment on table organization_name_resolution is 'Caches LLM-proposed canonical names for abbreviated/slang organization mentions (e.g. AGP -> Aurora Grid Power), cross-verified via external search before being trusted.'; +-- Searxng cross-check of a post abbreviation against an existing +-- customer-group tree node (ADR 0033). This is not ADR 0008's LLM +-- expansion and does not insert a corporate_entity row. A missing or +-- tied Searxng result leaves corporate_entity_id null. +create table abbreviation_tree_corroboration ( + abbreviation_tree_corroboration_id uuid primary key default uuid_generate_v4(), + raw_organization_name text not null unique, + corporate_entity_id uuid references corporate_entity (corporate_entity_id), + verification_status_code text not null references common_lookup_value (lookup_code), + verification_evidence_url text, + corroborated_at timestamptz not null default now() +); + +create index abbreviation_tree_corroboration_entity_idx + on abbreviation_tree_corroboration (corporate_entity_id) + where corporate_entity_id is not null; + +comment on table abbreviation_tree_corroboration is + 'Caches Searxng corroboration of a raw organization mention against an existing customer-group tree node. Fail-closed: no parent and no AUTO row when search is down, empty, or tied.'; + commit; diff --git a/migrations/0027_abbreviation_tree_corroboration.sql b/migrations/0027_abbreviation_tree_corroboration.sql new file mode 100644 index 00000000..349c4862 --- /dev/null +++ b/migrations/0027_abbreviation_tree_corroboration.sql @@ -0,0 +1,20 @@ +-- ADR 0033: persist Searxng abbreviation matches against the existing +-- customer-group tree. CREATE IF NOT EXISTS so a volume that already +-- ran 0001 still upgrades. Does not invent a parent or insert a +-- corporate_entity row. + +create table if not exists abbreviation_tree_corroboration ( + abbreviation_tree_corroboration_id uuid primary key default uuid_generate_v4(), + raw_organization_name text not null unique, + corporate_entity_id uuid references corporate_entity (corporate_entity_id), + verification_status_code text not null references common_lookup_value (lookup_code), + verification_evidence_url text, + corroborated_at timestamptz not null default now() +); + +create index if not exists abbreviation_tree_corroboration_entity_idx + on abbreviation_tree_corroboration (corporate_entity_id) + where corporate_entity_id is not null; + +comment on table abbreviation_tree_corroboration is + 'Caches Searxng corroboration of a raw organization mention against an existing customer-group tree node. Fail-closed: no parent and no AUTO row when search is down, empty, or tied.'; diff --git a/pyproject.toml b/pyproject.toml index b32772d2..c84dc14f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.10.4" +version = "2.11.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 3dbb5ea6..1a229e70 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -130,6 +130,7 @@ def seed( cur.execute((migrations / "0024_source_post_revision.sql").read_text()) cur.execute((migrations / "0025_role_person_catalog_identity.sql").read_text()) cur.execute((migrations / "0026_report_leftover_pair.sql").read_text()) + cur.execute((migrations / "0027_abbreviation_tree_corroboration.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values @@ -181,6 +182,28 @@ def seed( (group_entity_id,), ) corporate_entity_id = cur.fetchone()[0] + cur.execute( + "insert into corporate_entity (parent_entity_id, corporate_entity_code, entity_name, entity_level_code) " + "values (%s, 'DEMO-PLANT-01', 'Demo Plant', 'plant') " + "on conflict (corporate_entity_code) do update set " + "entity_name = excluded.entity_name, " + "entity_level_code = excluded.entity_level_code, " + "parent_entity_id = excluded.parent_entity_id", + (corporate_entity_id,), + ) + cur.execute( + "insert into abbreviation_tree_corroboration " + "(raw_organization_name, corporate_entity_id, " + " verification_status_code, verification_evidence_url) " + "values ('DC', %s, 'verify_corroborated', " + " 'https://example.test/demo-corp-dc') " + "on conflict (raw_organization_name) do update set " + "corporate_entity_id = excluded.corporate_entity_id, " + "verification_status_code = excluded.verification_status_code, " + "verification_evidence_url = excluded.verification_evidence_url, " + "corroborated_at = now()", + (corporate_entity_id,), + ) cur.execute( "insert into process_unit (corporate_entity_id, process_unit_code, process_unit_name) values " diff --git a/tests/test_abbreviation_tree_corroboration.py b/tests/test_abbreviation_tree_corroboration.py new file mode 100644 index 00000000..e3c4bce9 --- /dev/null +++ b/tests/test_abbreviation_tree_corroboration.py @@ -0,0 +1,130 @@ +"""Tree-constrained Searxng abbreviation cross-check (ADR 0033).""" + +from __future__ import annotations + +import pytest + +from lineageweave.abbreviation_tree_corroboration import ( + AbbreviationTreeMatch, + TreeEntityCandidate, + abbreviation_candidates, + corroborate_abbreviation_against_tree, + exact_catalog_matches, +) +from lineageweave.relation_verification import ( + STATUS_CORROBORATED, + STATUS_PENDING, + STATUS_UNCORROBORATED, + NullRelationVerificationClient, + RelationVerificationResult, +) + +_TREE = ( + TreeEntityCandidate("group-id", "Demo Group"), + TreeEntityCandidate("corp-id", "Demo Corp"), + TreeEntityCandidate("plant-id", "Demo Plant"), +) + + +class _FakeVerificationClient: + available = True + + def __init__(self, hits: dict[tuple[str, str], RelationVerificationResult]) -> None: + self._hits = hits + self.calls: list[tuple[str, str]] = [] + + def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: + self.calls.append((organization_name, relationship_label)) + return self._hits.get( + (organization_name, relationship_label), + RelationVerificationResult(STATUS_UNCORROBORATED, None), + ) + + +class _RaisingVerificationClient: + available = True + + def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: + raise RuntimeError("searxng timeout") + + +def test_exact_catalog_name_is_not_an_abbreviation_candidate() -> None: + assert abbreviation_candidates(("Demo Corp", "DC", " "), _TREE) == ("DC",) + + +def test_tied_exact_catalog_names_stay_candidates() -> None: + twins = ( + TreeEntityCandidate("a", "Demo Twin"), + TreeEntityCandidate("b", "Demo Twin"), + ) + assert abbreviation_candidates(("Demo Twin",), twins) == ("Demo Twin",) + + +def test_unique_searxng_hit_binds_the_tree_node() -> None: + client = _FakeVerificationClient( + { + ("Demo Corp", "DC"): RelationVerificationResult( + STATUS_CORROBORATED, "https://example.test/demo-corp-dc" + ) + } + ) + match = corroborate_abbreviation_against_tree("DC", _TREE, client) + assert match == AbbreviationTreeMatch( + raw_organization_name="DC", + corporate_entity_id="corp-id", + verification_status_code=STATUS_CORROBORATED, + verification_evidence_url="https://example.test/demo-corp-dc", + ) + assert ("Demo Group", "DC") in client.calls + assert ("Demo Plant", "DC") in client.calls + + +def test_no_searxng_hit_stays_unbound() -> None: + match = corroborate_abbreviation_against_tree("ZZ", _TREE, _FakeVerificationClient({})) + assert match.corporate_entity_id is None + assert match.verification_status_code == STATUS_UNCORROBORATED + assert match.verification_evidence_url is None + + +def test_tied_searxng_hits_stay_unbound() -> None: + client = _FakeVerificationClient( + { + ("Demo Corp", "DX"): RelationVerificationResult( + STATUS_CORROBORATED, "https://example.test/demo-corp" + ), + ("Demo Group", "DX"): RelationVerificationResult( + STATUS_CORROBORATED, "https://example.test/demo-group" + ), + } + ) + match = corroborate_abbreviation_against_tree("DX", _TREE, client) + assert match.corporate_entity_id is None + assert match.verification_status_code == STATUS_UNCORROBORATED + + +def test_unavailable_searxng_is_pending_and_does_not_invent_a_parent() -> None: + match = corroborate_abbreviation_against_tree("DC", _TREE, NullRelationVerificationClient()) + assert match.corporate_entity_id is None + assert match.verification_status_code == STATUS_PENDING + assert match.verification_evidence_url is None + + +def test_empty_mention_is_uncorroborated() -> None: + match = corroborate_abbreviation_against_tree(" ", _TREE, _FakeVerificationClient({})) + assert match.corporate_entity_id is None + assert match.verification_status_code == STATUS_UNCORROBORATED + + +def test_search_failure_is_not_recorded_as_uncorroborated() -> None: + with pytest.raises(RuntimeError, match="searxng timeout"): + corroborate_abbreviation_against_tree("DC", _TREE, _RaisingVerificationClient()) + + +def test_exact_catalog_matches_normalize_legal_suffix() -> None: + matches = exact_catalog_matches("Demo Corp.", _TREE) + assert [row.entity_id for row in matches] == ["corp-id"] + + +def test_exact_catalog_matches_ignore_empty_normalized_names() -> None: + assert exact_catalog_matches(" ", _TREE) == () + assert exact_catalog_matches("Corp.", _TREE) == () diff --git a/tests/test_customer_group_tree.py b/tests/test_customer_group_tree.py new file mode 100644 index 00000000..852d11ad --- /dev/null +++ b/tests/test_customer_group_tree.py @@ -0,0 +1,69 @@ +"""Authorized customer-group forest: affiliated rows plus ancestors/descendants.""" + +from __future__ import annotations + +from lineageweave.customer_group_tree import ( + CatalogEntityRow, + TreeAbbreviation, + authorized_catalog_ids, + build_customer_group_forest, +) + +_ENTITIES = ( + CatalogEntityRow("group-id", None, "Demo Group", "group"), + CatalogEntityRow("corp-id", "group-id", "Demo Corp", "company"), + CatalogEntityRow("plant-id", "corp-id", "Demo Plant", "plant"), + CatalogEntityRow("other-id", None, "Other Corp", "group"), +) + + +def test_affiliated_company_includes_group_parent_and_plant_child() -> None: + needed = authorized_catalog_ids(_ENTITIES, ("corp-id",)) + assert needed == {"group-id", "corp-id", "plant-id"} + + +def test_unaffiliated_sibling_group_is_omitted() -> None: + forest = build_customer_group_forest(_ENTITIES, ("corp-id",)) + assert [node.entity_name for node in forest] == ["Demo Group"] + group = forest[0] + assert [child.entity_name for child in group.children] == ["Demo Corp"] + assert [child.entity_name for child in group.children[0].children] == ["Demo Plant"] + + +def test_unknown_affiliation_adds_no_invented_parent() -> None: + forest = build_customer_group_forest(_ENTITIES, ("missing-id",)) + assert forest == () + + +def test_broken_parent_pointer_stops_without_inventing_an_ancestor() -> None: + broken = ( + CatalogEntityRow("corp-id", "missing-parent", "Demo Corp", "company"), + ) + assert authorized_catalog_ids(broken, ("corp-id",)) == {"corp-id"} + + +def test_already_included_descendant_is_not_walked_twice() -> None: + needed = authorized_catalog_ids(_ENTITIES, ("plant-id", "group-id")) + assert needed == {"group-id", "corp-id", "plant-id"} + + +def test_corroborated_abbreviation_attaches_only_to_authorized_nodes() -> None: + forest = build_customer_group_forest( + _ENTITIES, + ("corp-id",), + ( + ( + "corp-id", + TreeAbbreviation("DC", "verify_corroborated", "https://example.test/demo-corp-dc"), + ), + ( + "other-id", + TreeAbbreviation("OC", "verify_corroborated", "https://example.test/other"), + ), + ), + ) + company = forest[0].children[0] + assert [alias.raw_organization_name for alias in company.abbreviations] == ["DC"] + assert forest[0].to_dict()["children"][0]["abbreviations"][0]["raw_organization_name"] == "DC" + serialized = forest[0].to_dict() + assert "Other Corp" not in str(serialized) diff --git a/tests/test_customer_group_tree_labels.py b/tests/test_customer_group_tree_labels.py new file mode 100644 index 00000000..592496de --- /dev/null +++ b/tests/test_customer_group_tree_labels.py @@ -0,0 +1,36 @@ +"""Lookup-label helpers for the customer-group JSON forest.""" + +from __future__ import annotations + +from backend.app.customer_group_tree_ingestion import _apply_lookup_labels, _collect_level_codes + + +def test_collect_level_codes_walks_nested_children() -> None: + codes = _collect_level_codes( + [ + { + "entity_level_code": "group", + "children": [ + { + "entity_level_code": "company", + "children": [{"entity_level_code": "plant", "children": []}], + } + ], + } + ] + ) + assert codes == ["group", "company", "plant"] + + +def test_apply_lookup_labels_falls_back_to_the_code() -> None: + forest = [ + { + "entity_level_code": "group", + "children": [{"entity_level_code": "company", "children": []}], + }, + {"entity_level_code": None, "children": []}, + ] + _apply_lookup_labels(forest, {"group": "Group"}) + assert forest[0]["entity_level_label"] == "Group" + assert forest[0]["children"][0]["entity_level_label"] == "company" + assert forest[1]["entity_level_label"] is None diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py index e576d519..53a93cb2 100644 --- a/tests/test_documentation_hygiene.py +++ b/tests/test_documentation_hygiene.py @@ -73,15 +73,20 @@ def test_role_catalog_identity_migration_is_wired() -> None: assert "0019_role_catalog_identity.sql" in dockerfile assert "0025_role_person_catalog_identity.sql" in dockerfile assert "0026_report_leftover_pair.sql" in dockerfile + assert "0027_abbreviation_tree_corroboration.sql" in dockerfile assert "0019_role_catalog_identity.sql" in seed assert "0025_role_person_catalog_identity.sql" in seed assert "0026_report_leftover_pair.sql" in seed + assert "0027_abbreviation_tree_corroboration.sql" in seed assert seed.index("0024_source_post_revision.sql") < seed.index( "0025_role_person_catalog_identity.sql" ) assert seed.index("0025_role_person_catalog_identity.sql") < seed.index( "0026_report_leftover_pair.sql" ) + assert seed.index("0026_report_leftover_pair.sql") < seed.index( + "0027_abbreviation_tree_corroboration.sql" + ) assert "cataloged_person_id" in seed assert "order by created_at, person_id limit 1" in seed assert "cataloged_team_id" in migration_0019 diff --git a/tests/test_schema.py b/tests/test_schema.py index 076cabb9..3dd99b31 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -106,6 +106,7 @@ def test_migration_applies_cleanly(schema_db) -> None: "post_summary_role", "post_chat_result", "post_chat_citation", + "abbreviation_tree_corroboration", } assert expected <= tables diff --git a/uv.lock b/uv.lock index 025598df..7deb00d4 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.10.3" +version = "2.11.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 4270978be75137f9e3bcced25260cc8f81d30661 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:38:58 +0900 Subject: [PATCH 156/161] feat: persist Succeeded TEPP time/multilevel results (v2.12.0) Persist time/multilevel/multi-affiliation TEPP aggregates on the analysis-run and mark Succeeded. Fail-closed for missing transport, accepted-only acks, and theta/IRT/topic envelopes. Synthetic Demo Corp seed only. ADR 0034, v2.12.0. Do not merge #74 onto main. --- AGENTS.md | 4 +- ARCHITECTURE.md | 9 +- CHANGELOG.d/2.12.0-persistable-tepp-result.md | 8 + CHANGELOG.md | 15 ++ CLAUDE.md | 19 +- backend/app/analysis_run_ingestion.py | 42 +++- backend/app/analysis_run_start.py | 67 +++++- backend/app/main.py | 4 +- backend/tests/test_api.py | 100 +++++++++ docker/postgres-init/Dockerfile | 1 + .../0013-normalized-analysis-run-registry.md | 6 +- docs/adr/0014-authorized-analysis-run-read.md | 23 +- docs/adr/0022-authorized-tepp-start.md | 2 +- docs/adr/0034-persistable-tepp-result.md | 107 +++++++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 2 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 35 ++- frontend/src/App.tsx | 39 +++- frontend/src/api.ts | 5 + lineageweave/__init__.py | 2 +- lineageweave/tepp_result.py | 168 ++++++++++++++ migrations/0028_analysis_run_tepp_result.sql | 211 ++++++++++++++++++ .../0028_analysis_run_tepp_result.sql | 27 +++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 118 +++++++++- ...test_analysis_run_reconstruction_schema.py | 1 + tests/test_analysis_run_registry_schema.py | 7 + tests/test_analysis_run_start.py | 24 +- tests/test_analysis_run_tepp_result_schema.py | 139 ++++++++++++ tests/test_seed_tepp_run.py | 34 +++ tests/test_tepp_result.py | 56 +++++ uv.lock | 2 +- 32 files changed, 1221 insertions(+), 60 deletions(-) create mode 100644 CHANGELOG.d/2.12.0-persistable-tepp-result.md create mode 100644 docs/adr/0034-persistable-tepp-result.md create mode 100644 lineageweave/tepp_result.py create mode 100644 migrations/0028_analysis_run_tepp_result.sql create mode 100644 migrations/rollback/0028_analysis_run_tepp_result.sql create mode 100644 tests/test_analysis_run_tepp_result_schema.py create mode 100644 tests/test_tepp_result.py diff --git a/AGENTS.md b/AGENTS.md index ae19095b..3a64c119 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,9 @@ commitment derivation go through contextual-orchestrator the same way adjudication does -- never a raw LLM API. Demo TEPP seed goes through `tepp_client` the same way: a missing transport or an unused accepted envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`), -never a fabricated theta or a local psychometric substitute. +never a fabricated theta or a local psychometric substitute. A +persistable time / multilevel / multi-affiliation envelope is +Succeeded (ADR 0034). ## Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 42fed5d8..7fc3647a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -496,6 +496,9 @@ Event Lineage panel as that run's tree. `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. +A second Demo Corp TEPP run uses an in-process persistable +time / multilevel / multi-affiliation envelope and is Succeeded +(ADR 0034). The home list is clickable: `GET /api/analysis-runs/{id}` fills a labeled detail (cutoff, requested date, 12-character digest prefixes with full digests on hover, counts, status history) @@ -521,8 +524,10 @@ source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · Demo Corp" with "3 documents" and Pending / Running / Succeeded times, the designed A-100 fork as clickable reconstructed edges, Claimed -then Delivered outbox times, and "TEPP measurement · Failed · Demo -Corp" whose detail history ends in Failed / `tepp_not_available`. +then Delivered outbox times, "TEPP measurement · Failed · Demo +Corp" whose detail history ends in Failed / `tepp_not_available`, +and "TEPP measurement · Succeeded · Demo Corp" with measured clocks +and affiliation counts (ADR 0034). Seed also records "Period report · Succeeded · Demo Corp" on that same snapshot after the calibrated report tables are written (ADR 0024). Open that row to confirm the cutoff posts; mean θ stays diff --git a/CHANGELOG.d/2.12.0-persistable-tepp-result.md b/CHANGELOG.d/2.12.0-persistable-tepp-result.md new file mode 100644 index 00000000..cba49b6f --- /dev/null +++ b/CHANGELOG.d/2.12.0-persistable-tepp-result.md @@ -0,0 +1,8 @@ +# 2.12.0 Persistable TEPP result is Succeeded + +A live TEPP transport that returns a time / multilevel / +multi-affiliation envelope is stored on the analysis-run and marked +Succeeded. Home list and detail show clocks and affiliation counts. +A screen reader on that Succeeded Demo Corp row hears the next action. +An accepted ack or a missing transport stays Failed. No invented theta +(ADR 0034). diff --git a/CHANGELOG.md b/CHANGELOG.md index b80b9092..f94a8db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ 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.12.0] - 2026-08-17 + +### Added + +- A persistable TEPP **time / multilevel / multi-affiliation** result + is stored on the analysis-run and marked Succeeded (ADR 0034). After + `make seed`, Demo Analyst sees **TEPP measurement · Succeeded · Demo + Corp** next to the Failed missing-transport row. Home list and detail + show measured clocks and affiliation counts. A screen reader on that + Succeeded row hears open the run to read those aggregates, not only + the title. An `accepted` ack or an envelope this product cannot store + stays Failed / `tepp_result_not_persisted`. Missing + `TEPP_TRANSPORT_URL` stays Failed / `tepp_not_available`. Never + invent a theta. + ## [2.11.0] - 2026-08-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 34ffdc39..6c7f8abb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,14 +19,17 @@ identity on that role row is ADR 0027 (`cataloged_person_id`). ## Analysis-run seed (v0.96.0) -`make seed` writes a Demo Corp lineage run, a TEPP run, and a Succeeded -period-report run on the same snapshot (ADR 0013 / ADR 0024). The TEPP path goes through `tepp_client`. A missing -transport or an unused accepted envelope is Failed -(`tepp_not_available` / `tepp_result_not_persisted`). Do not invent a -theta or a local psychometric substitute. The home list caption stays -`kind · status · entity`; the machine failure code is detail-only -(ADR 0014). Open a Failed TEPP row, then connect a live TEPP -transport. A failed lineage row retries reconstruction -- it does not +`make seed` writes a Demo Corp lineage run, a Failed TEPP run, a +Succeeded TEPP run, and a Succeeded period-report run on the same +snapshot (ADR 0013 / ADR 0024 / ADR 0034). The TEPP path goes through +`tepp_client`. A missing transport or an unused accepted envelope is +Failed (`tepp_not_available` / `tepp_result_not_persisted`). A +persistable time / multilevel / multi-affiliation envelope is +Succeeded. Do not invent a theta or a local psychometric substitute. +The home list caption stays `kind · status · entity`; the machine +failure code is detail-only (ADR 0014). Open a Failed TEPP row, then +connect a live TEPP transport. Open a Succeeded TEPP row to read the +measured clocks and affiliation counts. A failed lineage row retries reconstruction -- it does not mention TEPP. A failed period-report row rebuilds the report. A pending TEPP row does not claim a calibrated measurement and does not say reconstruction. The list button name includes the diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 50eb821a..e789cde2 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -11,8 +11,9 @@ 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. +ADR 0023) or submit TEPP through ``tepp_client`` (ADR 0022 / ADR 0034). +A persistable time / multilevel / multi-affiliation result is stored; +neither path invents a TEPP score. """ from __future__ import annotations @@ -183,6 +184,32 @@ def live_write_after_cutoff(updated_at: datetime, knowledge_cutoff: datetime) -> return _as_utc(updated_at) > _as_utc(knowledge_cutoff) +async def _tepp_results_by_run( + conn: asyncpg.Connection, + run_ids: list[str], +) -> dict[str, asyncpg.Record]: + """Load persistable TEPP aggregates for the given runs. + + Missing ``analysis_run_tepp_result`` means migration 0028 is not + applied. Treat that as no stored measurement rather than 500. + """ + if not run_ids: + return {} + try: + rows = await conn.fetch( + """ + select analysis_run_id, result_sha256, interval_count, + level_count, affiliation_count, measured_at + from analysis_run_tepp_result + where analysis_run_id = any($1::uuid[]) + """, + run_ids, + ) + except asyncpg.UndefinedTableError: + return {} + return {str(row["analysis_run_id"]): row for row in rows} + + async def _counts_by_run( conn: asyncpg.Connection, run_ids: list[str], @@ -282,7 +309,9 @@ async def _serialize_runs( """Project registry rows into the authorized buyer-facing payload.""" if not rows: return [] - count_rows = await _counts_by_run(conn, [str(row["analysis_run_id"]) for row in rows]) + run_ids = [str(row["analysis_run_id"]) for row in rows] + count_rows = await _counts_by_run(conn, run_ids) + tepp_rows = await _tepp_results_by_run(conn, run_ids) labels = await labels_for_codes( conn, [row["run_kind_code"] for row in rows] @@ -328,6 +357,13 @@ async def _serialize_runs( grouping_key = scope_grouping_key(row) if grouping_key: item["scope_grouping_key"] = grouping_key + tepp = tepp_rows.get(run_id) + if tepp is not None: + item["tepp_result_sha256"] = tepp["result_sha256"] + item["tepp_interval_count"] = int(tepp["interval_count"]) + item["tepp_level_count"] = int(tepp["level_count"]) + item["tepp_affiliation_count"] = int(tepp["affiliation_count"]) + item["tepp_measured_at"] = _iso(tepp["measured_at"]) payload.append(item) return payload diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 37058d11..a0ea85aa 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -2,9 +2,10 @@ ADR 0021 reconstructs lineage. ADR 0022 starts TEPP through ``tepp_client`` only. ADR 0023 enqueues that work on a durable outbox -so a crash after Running does not lose the item. Period-report stays -another path. Neither start invents a theta or a calibrated report -score. +so a crash after Running does not lose the item. ADR 0034 persists a +time / multilevel / multi-affiliation TEPP result and stamps Succeeded. +Period-report stays another path. Neither start invents a theta or a +calibrated report score. """ from __future__ import annotations @@ -31,6 +32,7 @@ from lineageweave.lineage_persistence import lineage_edge_specs from lineageweave.models import Edge from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable +from lineageweave.tepp_result import TeppPersistableResult, parse_persistable_tepp_result _LINEAGE_KIND = "analysis_run_lineage" _TEPP_KIND = "analysis_run_tepp" @@ -130,18 +132,22 @@ def tepp_run_request( def tepp_submit_outcome( client: TeppClient, request: AnalysisRunRequest, -) -> tuple[str, str]: +) -> tuple[str, str | None, TeppPersistableResult | None]: """Submit through ``tepp_client``. Never invent or persist a theta. - A missing transport is ``tepp_not_available``. An accepted envelope - is not a persistable measurement until TEPP publishes one, so the - run stays Failed / ``tepp_result_not_persisted``. + A missing transport is ``tepp_not_available``. An accepted ack or + any envelope this product cannot store is Failed / + ``tepp_result_not_persisted``. A persistable time / multilevel / + multi-affiliation result is Succeeded and returned for storage. """ try: - client.submit_analysis_run(request) + envelope = client.submit_analysis_run(request) except TeppNotAvailable: - return _FAILED, "tepp_not_available" - return _FAILED, "tepp_result_not_persisted" + return _FAILED, "tepp_not_available", None + parsed = parse_persistable_tepp_result(envelope) + if parsed is None: + return _FAILED, "tepp_result_not_persisted", None + return _SUCCEEDED, None, parsed def start_write_conflict_error() -> AnalysisRunStartError: @@ -507,7 +513,8 @@ async def deliver_queued_analysis_run( A delivered row replays the stored result. Missing work is 409. TEPP stays Failed when the transport is missing or the envelope is - not persistable. No theta is invented. + not persistable. A persistable time / multilevel / multi-affiliation + result is stored and the run is Succeeded. No theta is invented. """ try: UUID(analysis_run_id) @@ -690,6 +697,36 @@ async def _deliver_lineage_reconstruction( ) +async def _persist_tepp_result( + conn: asyncpg.Connection, + analysis_run_id: str, + result: TeppPersistableResult, + recorded_at: datetime, +) -> bool: + """Store persistable TEPP aggregates. Missing table is not success.""" + if recorded_at < result.measured_at: + recorded_at = result.measured_at + try: + await conn.execute( + """ + insert into analysis_run_tepp_result + (analysis_run_id, result_sha256, interval_count, level_count, + affiliation_count, measured_at, recorded_at) + values ($1, $2, $3, $4, $5, $6, $7) + """, + analysis_run_id, + result.result_sha256(), + result.interval_count, + result.level_count, + result.affiliation_count, + result.measured_at, + recorded_at, + ) + except asyncpg.UndefinedTableError: + return False + return True + + async def _deliver_tepp_measurement( conn: asyncpg.Connection, *, @@ -705,10 +742,16 @@ async def _deliver_tepp_measurement( knowledge_cutoff=locked["knowledge_cutoff"], corporate_entity_id=str(locked["corporate_entity_id"]), ) - status_code, failure_code = tepp_submit_outcome(tepp_client, request) + status_code, failure_code, persistable = tepp_submit_outcome(tepp_client, request) finished = datetime.now(timezone.utc) if finished < now: finished = now + if persistable is not None: + stored = await _persist_tepp_result( + conn, analysis_run_id, persistable, finished + ) + if not stored: + status_code, failure_code = _FAILED, "tepp_result_not_persisted" await _append_status( conn, analysis_run_id, diff --git a/backend/app/main.py b/backend/app/main.py index a3ac770f..db37b039 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1420,7 +1420,9 @@ async def start_analysis_run( post_read is enough. Hidden runs 404. Period-report is 422 so this path cannot invent a calibrated score. TEPP goes through ``tepp_client`` and stays Failed when the transport is missing or - the envelope is not persistable. A Succeeded lineage retry returns + the envelope is not persistable. A persistable time / multilevel / + multi-affiliation result is stored and the run is Succeeded. A + Succeeded lineage retry returns the stored tree. A Running restart with an undelivered outbox finishes that work. A Running restart without pending work is 409. The outbox commits before reconstruct/TEPP so a crash leaves a diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 701168bd..a11a65ad 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -23,6 +23,8 @@ from lineageweave.http_client import HttpClientError, get_json, post_form from lineageweave.knowledge_graph import knowledge_graph_edges_for_post +from lineageweave.tepp_client import TeppClient +from lineageweave.tepp_result import persistable_tepp_seed_envelope _POSTGRES_ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave" @@ -45,6 +47,9 @@ _REVISION_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0024_source_post_revision.sql" ) +_TEPP_RESULT_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0028_analysis_run_tepp_result.sql" +) def _postgres_available() -> bool: @@ -133,6 +138,7 @@ def seeded_db(demo_analyst_token): cur.execute(_SNAPSHOT_MEMBER_MIGRATION.read_text()) cur.execute(_OUTBOX_MIGRATION.read_text()) cur.execute(_REVISION_MIGRATION.read_text()) + cur.execute(_TEPP_RESULT_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -1041,6 +1047,100 @@ def test_start_analysis_run_recovers_the_a100_fork( assert "Pricing renegotiation: revised quote sent" in children +def test_tepp_start_persists_a_persistable_envelope( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """A persistable TEPP envelope is stored and the run is Succeeded.""" + monkeypatch.setattr( + "backend.app.main.configured_tepp_client", + lambda _url="": TeppClient( + transport=lambda _payload: persistable_tepp_seed_envelope() + ), + ) + 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", + (seeded_db["visible_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 + """, + ("8" * 64,), + ) + 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-persistable', + %s, '2026-02-15T00:00:00Z', 'tepp-run-v1', %s, %s, + '2026-02-15T12:30:00Z') + returning analysis_run_id + """, + (snapshot_id, requester_id, "7" * 64, "6" * 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_run_id}/start", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert measured.status_code == 200, measured.text + body = measured.json() + assert body["status_label"] == "Succeeded" + assert "failure_code" not in body + assert body["tepp_affiliation_count"] == 2 + assert body["tepp_interval_count"] == 2 + assert body["tepp_level_count"] == 3 + assert body["tepp_measured_at"] + assert len(body["tepp_result_sha256"]) == 64 + assert "theta" not in str(body).lower() + + listed = client.get( + "/api/analysis-runs", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert listed.status_code == 200 + listed_run = next( + run for run in listed.json()["analysis_runs"] if run["analysis_run_id"] == tepp_run_id + ) + assert listed_run["status_label"] == "Succeeded" + assert listed_run["tepp_affiliation_count"] == 2 + assert listed_run["tepp_measured_at"] + + def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> None: response = client.get("/api/me", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index f37a17e4..b603bd7f 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -33,6 +33,7 @@ COPY migrations/0024_source_post_revision.sql /docker-entrypoint-initdb.d/25-sou COPY migrations/0025_role_person_catalog_identity.sql /docker-entrypoint-initdb.d/26-role-person-catalog-identity.sql COPY migrations/0026_report_leftover_pair.sql /docker-entrypoint-initdb.d/27-report-leftover-pair.sql COPY migrations/0027_abbreviation_tree_corroboration.sql /docker-entrypoint-initdb.d/28-abbreviation-tree-corroboration.sql +COPY migrations/0028_analysis_run_tepp_result.sql /docker-entrypoint-initdb.d/29-analysis-run-tepp-result.sql # Official image already drops to this account at runtime; declare it so # the Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER postgres diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index 15fd040d..f70c966b 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -252,8 +252,10 @@ Acceptance requires: 4. Add TEPP and contextual-orchestrator adapters only after their versioned contracts are present on reviewed main branches. Seed and `POST /api/analysis-runs/{id}/start` now record Failed TEPP through - `tepp_client` on the frozen snapshot; a persistable measurement - remains a later slice. A missing or unused TEPP envelope must stay + `tepp_client` on the frozen snapshot when the transport is missing + or the envelope is not persistable. A persistable time / multilevel / + multi-affiliation result is stored and Succeeded (ADR 0034). A + missing or unused TEPP envelope must stay Failed (`tepp_not_available` / `tepp_result_not_persisted`) and must not write a local psychometric substitute. Seed also records a Succeeded `analysis_run_report` on that snapshot after the diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 4d59ceb9..873799e2 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -38,14 +38,15 @@ LineageWeave owns a fail-closed read projection of the #89 registry: ## Consequences -`make seed` writes one synthetic Demo Corp lineage run, one TEPP -run, and one Succeeded period-report run on the same snapshot so the -existing React home page can show all three kinds without a second -application (ADR 0024). The TEPP run is Failed / -`tepp_not_available` when the default transport is missing -- the list -keeps that machine code off the caption (this decision) and instead -tells the operator to open the TEPP run, then connect the measurement -service. A failed lineage row tells the operator to retry +`make seed` writes one synthetic Demo Corp lineage run, one Failed +TEPP run, one Succeeded TEPP run, and one Succeeded period-report run +on the same snapshot so the existing React home page can show all +three kinds without a second application (ADR 0024 / ADR 0034). The +Failed TEPP run is `tepp_not_available` when the default transport is +missing -- the list keeps that machine code off the caption (this +decision) and instead tells the operator to open the TEPP run, then +connect the measurement service. The Succeeded TEPP row tells the +operator to read the measured clocks and affiliation counts. A failed lineage row tells the operator to retry reconstruction, not to connect TEPP. A failed period-report row tells the operator to rebuild the report from a current snapshot. A pending or running TEPP row must not claim a calibrated @@ -58,9 +59,9 @@ in that name. Detail repeats that sentence. 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 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. A 404 on a hidden run (including a thread-group row that +(ADR 0017). TEPP and period-report kinds are 422. Reconstruction and +a persistable TEPP result are ADR 0021 / ADR 0034. A fuller Analysis +Run Console remains a later slice. A 404 on a hidden run (including a thread-group row that still lacks an in-cutoff visible post, ADR 0018) must stay generic: do not name the thread or the cutoff, and do not say the run is not visible. Tell the operator to open a visible run from the home list, diff --git a/docs/adr/0022-authorized-tepp-start.md b/docs/adr/0022-authorized-tepp-start.md index 84fc7163..3093a084 100644 --- a/docs/adr/0022-authorized-tepp-start.md +++ b/docs/adr/0022-authorized-tepp-start.md @@ -41,7 +41,7 @@ authorized transaction: or refused, or Failed / `tepp_result_not_persisted` when TEPP accepts an envelope this product cannot store yet. -Succeeded TEPP stays later. This slice does not persist a local +Succeeded TEPP is ADR 0034. 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. `POST /api/analysis-runs` is lineage-only (ADR 0017) and does diff --git a/docs/adr/0034-persistable-tepp-result.md b/docs/adr/0034-persistable-tepp-result.md new file mode 100644 index 00000000..2875820e --- /dev/null +++ b/docs/adr/0034-persistable-tepp-result.md @@ -0,0 +1,107 @@ +# ADR 0034 — Persistable TEPP time / multilevel / multi-affiliation results become Succeeded + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-17 +**Depends on:** ADR 0013 registry; ADR 0014 authorized read; ADR 0022 +authorized TEPP start; ADR 0023 durable outbox +**Refs:** Issue #79 (Milestone 2 parent); ADR 0022 left Succeeded TEPP +as a later slice + +## Context + +ADR 0022 submits TEPP's published `AnalysisRunRequest` on +`POST /api/analysis-runs/{id}/start`. A missing or refused transport +is Failed / `tepp_not_available`. An `accepted` ack is Failed / +`tepp_result_not_persisted`. That fail-closed path is honest, but a +buyer who connects a live TEPP transport still cannot see that a +**time / multilevel / multi-affiliation** measurement landed. + +This product must not invent a theta, IRT item parameter, topic, or +ALR score. Those stay in TEPP and fast-mlsirm. The missing work is to +store the persistable aggregates TEPP already returned and mark that +run Succeeded. + +## Decision + +1. **Persistable envelope.** `lineageweave.tepp_result` accepts only + contract version 1 with `result_kind` + `time_multilevel_multi_affiliation`, a measured clock, and + non-negative `interval_count`, `level_count`, and + `affiliation_count`. An `accepted` ack, a theta, IRT item + parameters, or a topic/ALR payload is not persistable. +2. **Start.** `_deliver_tepp_measurement` still submits through + `tepp_client`. A persistable envelope is written to + `analysis_run_tepp_result` and the run appends Succeeded. Missing + transport stays Failed / `tepp_not_available`. An envelope this + product cannot store, including a missing result table, stays + Failed / `tepp_result_not_persisted`. Succeeded is never stamped + from a mere `accepted` ack. +3. **Authorized read.** List and detail project clocks, affiliation + counts, interval counts, level counts, and the result digest. + No theta and no provider body. +4. **Home copy.** A Succeeded TEPP row tells the operator to open the + run and read the measured clocks and affiliation counts. The list + button accessible name includes that sentence (WCAG 2.2 SC 4.1.2). +5. **Seed.** Demo Corp keeps the Failed / `tepp_not_available` row + for a missing transport. Seed also records + **TEPP measurement · Succeeded · Demo Corp** from an in-process + persistable envelope on the same snapshot. Public git stays + synthetic Demo Corp only. + +Period-report start stays 422. Create stays lineage-only. IRT leftover +pairs and RankWeave fusion are unchanged. + +```mermaid +sequenceDiagram + participant Operator + participant API + participant TeppClient + participant Registry + Operator->>API: POST /api/analysis-runs/{id}/start + API->>Registry: lock visible Pending TEPP run + Registry->>Registry: Running + API->>TeppClient: AnalysisRunRequest v1 + alt TeppNotAvailable + Registry->>Registry: Failed tepp_not_available + else persistable time / multilevel / multi-affiliation + Registry->>Registry: analysis_run_tepp_result + Succeeded + else accepted or unstoreable envelope + Registry->>Registry: Failed tepp_result_not_persisted + end + API-->>Operator: 200 status history +``` + +## Consequences + +After `make seed`, Demo Analyst sees both the Failed TEPP row and +**TEPP measurement · Succeeded · Demo Corp**. Opening the Succeeded +row shows measured clocks and affiliation counts. A screen reader on +that list button hears the next action, not only the title. Connecting +`TEPP_TRANSPORT_URL` can now finish a Pending TEPP run as Succeeded +when the transport returns a persistable envelope. Do not invent a +theta. + +Existing volumes apply `0028_analysis_run_tepp_result.sql`. Granted +retention purge empties `analysis_run_tepp_result` when that table +exists. + +## References — APA 7th + +American Educational Research Association, American Psychological +Association, & National Council on Measurement in Education. (2014). +*Standards for educational and psychological testing*. American +Educational Research Association. + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. +World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C +Recommendation). https://www.w3.org/TR/owl-time/ + +World Wide Web Consortium. (2023). *Web content accessibility +guidelines (WCAG) 2.2* (W3C Recommendation). +https://www.w3.org/TR/WCAG22/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index 81b17866..44b9ed70 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -80,7 +80,7 @@ provenance, retention, and immutable evidence rather than blanket masking. | Idempotency is actor-scoped | Permit identical opaque keys for two accounts and reject reuse by the same account. | | Lifecycle is ordered | Require pending first, contiguous ordinals, monotonic time, legal transitions, terminal finality, and append-only rows. | | Rollback does not erase audit data silently | Reject 0018 rollback with any registry rows. A run-bearing registry empties only through an unrevoked `analysis_run_retention_grant` plus `analysis_run_retention_admin`, then `purge_analysis_run_registry('approved-retention-purge')`; a wrong token, a raw `DELETE`, and a runtime role that only knows the public phrase stay rejected. When `analysis_run_reconstruction` children exist, that same call empties them despite delete-reject triggers (ADR 0032). Export then delete `analysis_run_retention_event` before 0020 rollback. | -| Start reconstruction recovers the designed tree | Persist edges from `lineage_edge_specs` on the A-100 fixture bag via `records_from_source_posts`; the pricing follow-up must parent both the revised quote and the delivery question. A period-report start must 422 without a theta. TEPP start submits through `tepp_client` and stays Failed (`tepp_not_available` / `tepp_result_not_persisted`) without a theta. Snapshot members exclude a later backfill. A concurrent or Running start is 409. A Succeeded retry returns the stored digest. | +| Start reconstruction recovers the designed tree | Persist edges from `lineage_edge_specs` on the A-100 fixture bag via `records_from_source_posts`; the pricing follow-up must parent both the revised quote and the delivery question. A period-report start must 422 without a theta. TEPP start submits through `tepp_client` and stays Failed (`tepp_not_available` / `tepp_result_not_persisted`) without a theta unless the transport returns a persistable time / multilevel / multi-affiliation result, which is stored and Succeeded (ADR 0034). Snapshot members exclude a later backfill. A concurrent or Running start is 409. A Succeeded retry returns the stored digest. | ## APA 7th references diff --git a/frontend/package.json b/frontend/package.json index b63549a0..9f1164ab 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.11.0", + "version": "2.12.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f54faee5..9422af81 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -327,6 +327,15 @@ describe("App, authenticated", () => { status_label: teppLabel, knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", + ...(options?.succeededTeppRun + ? { + tepp_affiliation_count: 2, + tepp_interval_count: 2, + tepp_level_count: 3, + tepp_measured_at: "2026-01-12T12:45:00Z", + tepp_result_sha256: "a".repeat(64), + } + : {}), source_counts: [ { count_type_code: "analysis_count_document", @@ -695,6 +704,15 @@ describe("App, authenticated", () => { : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", + ...(options?.succeededTeppRun + ? { + tepp_affiliation_count: 2, + tepp_interval_count: 2, + tepp_level_count: 3, + tepp_measured_at: "2026-01-12T12:45:00Z", + tepp_result_sha256: "a".repeat(64), + } + : {}), source_counts: [ { count_type_code: "analysis_count_document", @@ -2875,15 +2893,22 @@ describe("App, authenticated", () => { stubBackend({ succeededTeppRun: true }); render(); - await userEvent.click( - await screen.findByRole("button", { - name: "Open analysis run: TEPP measurement · Succeeded · Demo Corp", - }), - ); + const succeeded = await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Succeeded · Demo Corp. Open this run to read the measured clocks and affiliation counts.", + }); + expect(succeeded).toHaveAccessibleName(/measured clocks and affiliation counts/); + const list = screen.getByRole("list", { name: "Analysis runs" }); + expect(list).toHaveTextContent("2 affiliations"); + expect(list).toHaveTextContent("Measured 2026-01-12"); + await userEvent.click(succeeded); expect( await screen.findByText("These posts are the cutoff corpus this TEPP run measured."), ).toBeInTheDocument(); + expect(screen.getAllByText(/2 affiliations/).length).toBeGreaterThan(0); + expect(screen.getByText(/2 intervals/)).toBeInTheDocument(); + expect(screen.getByText(/3 levels/)).toBeInTheDocument(); expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/theta/i)).not.toBeInTheDocument(); }); it("records a pending lineage run and opens the authorized detail", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 93b45a2c..99fd3ff6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1903,6 +1903,17 @@ function analysisRunNextAction(run: AnalysisRun): string | null { case "analysis_status_running": return "Refresh this run. Start already queued the work on the durable outbox."; case "analysis_status_succeeded": + switch (run.run_kind_code) { + case "analysis_run_tepp": + return "Open this run to read the measured clocks and affiliation counts."; + case "analysis_run_lineage": + case "analysis_run_report": + return null; + default: { + const unexpected: never = run.run_kind_code; + return unexpected; + } + } case "analysis_status_cancelled": case null: return null; @@ -1920,7 +1931,8 @@ function analysisRunNextAction(run: AnalysisRun): string | null { * Description Computation 1.1). When a next action exists, the name is * `Open analysis run: {caption}. {nextAction}` so a screen reader hears * what to do next, not only the run title (WCAG 2.2 SC 4.1.2). Succeeded - * and cancelled rows keep the caption alone. + * TEPP rows keep a next action. Other succeeded and cancelled rows keep + * the caption alone. */ function analysisRunAccessibleName(run: AnalysisRun): string { const caption = analysisRunCaption(run); @@ -2061,10 +2073,12 @@ function AnalysisRunReproducibilityDigests({ codeRevisionSha, configurationSha256, reconstructionResultSha256, + teppResultSha256, }: { codeRevisionSha?: string; configurationSha256?: string; reconstructionResultSha256?: string; + teppResultSha256?: string; }) { const parts: { label: string; digest: string }[] = []; if (codeRevisionSha) { @@ -2076,6 +2090,9 @@ function AnalysisRunReproducibilityDigests({ if (reconstructionResultSha256) { parts.push({ label: "Result", digest: reconstructionResultSha256 }); } + if (teppResultSha256) { + parts.push({ label: "TEPP", digest: teppResultSha256 }); + } if (parts.length === 0) { return null; } @@ -2364,6 +2381,12 @@ function AnalysisRunsPanel({ {documentCount.count_value} {documentCount.count_type_label.toLowerCase()} )} + {run.tepp_affiliation_count != null && ( + {run.tepp_affiliation_count} affiliations + )} + {run.tepp_measured_at && ( + Measured {run.tepp_measured_at.slice(0, 10)} + )} {nextAction && {nextAction}} @@ -2384,7 +2407,21 @@ function AnalysisRunsPanel({ codeRevisionSha={selected.code_revision_sha} configurationSha256={selected.configuration_sha256} reconstructionResultSha256={selected.reconstruction_result_sha256} + teppResultSha256={selected.tepp_result_sha256} /> + {selected.run_kind_code === "analysis_run_tepp" && + selected.tepp_measured_at && + selected.tepp_affiliation_count != null && ( +

    + Measured {selected.tepp_measured_at.slice(0, 16).replace("T", " ")} + {" · "} + {selected.tepp_affiliation_count} affiliations + {selected.tepp_interval_count != null + ? ` · ${selected.tepp_interval_count} intervals` + : ""} + {selected.tepp_level_count != null ? ` · ${selected.tepp_level_count} levels` : ""} +

    + )} {analysisRunCanStart(selected) && ( + + ) : ( +

    + No published accepted acknowledgement is stored on this run. +

    + )} +
    + ); +} + /** Git-style prefix. The full digest stays on `title` for verification. */ const ANALYSIS_RUN_DIGEST_PREFIX_LENGTH = 12; @@ -2073,12 +2158,12 @@ function AnalysisRunReproducibilityDigests({ codeRevisionSha, configurationSha256, reconstructionResultSha256, - teppResultSha256, + teppEvidenceSha256, }: { codeRevisionSha?: string; configurationSha256?: string; reconstructionResultSha256?: string; - teppResultSha256?: string; + teppEvidenceSha256?: string; }) { const parts: { label: string; digest: string }[] = []; if (codeRevisionSha) { @@ -2090,8 +2175,8 @@ function AnalysisRunReproducibilityDigests({ if (reconstructionResultSha256) { parts.push({ label: "Result", digest: reconstructionResultSha256 }); } - if (teppResultSha256) { - parts.push({ label: "TEPP", digest: teppResultSha256 }); + if (teppEvidenceSha256) { + parts.push({ label: "TEPP", digest: teppEvidenceSha256 }); } if (parts.length === 0) { return null; @@ -2381,11 +2466,8 @@ function AnalysisRunsPanel({ {documentCount.count_value} {documentCount.count_type_label.toLowerCase()} )} - {run.tepp_affiliation_count != null && ( - {run.tepp_affiliation_count} affiliations - )} - {run.tepp_measured_at && ( - Measured {run.tepp_measured_at.slice(0, 10)} + {run.tepp_evidence_kind && ( + {run.tepp_evidence_kind} )} {nextAction && {nextAction}} @@ -2407,21 +2489,11 @@ function AnalysisRunsPanel({ codeRevisionSha={selected.code_revision_sha} configurationSha256={selected.configuration_sha256} reconstructionResultSha256={selected.reconstruction_result_sha256} - teppResultSha256={selected.tepp_result_sha256} + teppEvidenceSha256={selected.tepp_evidence_sha256} /> - {selected.run_kind_code === "analysis_run_tepp" && - selected.tepp_measured_at && - selected.tepp_affiliation_count != null && ( -

    - Measured {selected.tepp_measured_at.slice(0, 16).replace("T", " ")} - {" · "} - {selected.tepp_affiliation_count} affiliations - {selected.tepp_interval_count != null - ? ` · ${selected.tepp_interval_count} intervals` - : ""} - {selected.tepp_level_count != null ? ` · ${selected.tepp_level_count} levels` : ""} -

    - )} + {selected.run_kind_code === "analysis_run_tepp" && ( + + )} {analysisRunCanStart(selected) && (