diff --git a/docs/adr/0143-lineage-isolation-reason.md b/docs/adr/0143-lineage-isolation-reason.md new file mode 100644 index 000000000..580db4100 --- /dev/null +++ b/docs/adr/0143-lineage-isolation-reason.md @@ -0,0 +1,41 @@ +# ADR 0143 — Explain an empty focused Event Lineage graph + +**Decision status:** Accepted +**Date:** 2026-08-22 + +## Context + +The focused Event Lineage endpoint can return an empty graph for two materially +different reasons: the authorized post has other visible comparison-group +members but no persisted link, or it is the only visible member of its group. +The former does not prove that a completed reconstruction considered the newest +source rows, and the latter must not be inferred from hidden posts. A generic +"No linked posts yet" message concealed this distinction. + +`thread_group_key` presence is not sufficient evidence because import may fall +back to process-unit or corporate-entity scope. The only supported diagnostic +available without a new heuristic is the size of the ABAC-visible +`reconstruct_group_key` group already fetched for the focused graph. + +## Decision + +1. A focused `GET /api/lineage?post_id=...` adds `isolation_reason` only as an + additive response field. It is `null` for landing graphs, inaccessible + posts, and non-empty focused graphs. +2. `comparison_candidates_available` means another ABAC-visible post shares + the comparison group, while the current projection has no Event Lineage + link. It does not claim a rebuild ran or considered the latest rows. +3. `no_comparison_group` means the focused post is the only ABAC-visible member + of its comparison group. Hidden posts cannot change the result or bridge a + visible component. +4. The buyer surface names both states and gives a next action. Unknown or + absent values retain the generic message for backward compatibility. +5. The batched multi-post Ask graph does not emit a single focused-post reason; + attributing one reason to that multi-focus projection would be unsupported. + +## Consequences + +This is a read-only diagnostic. It changes no reconstruction channel, score, +weight, threshold, or persistence policy and introduces no new query. Runtime +acceptance still requires authorized, non-identifying aggregate evidence; an +empty graph is never evidence that every current source row was reconstructed. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 98877418a..bfdd0bc50 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -119,6 +119,7 @@ describe("App, authenticated", () => { organizationAliases?: boolean; askLineageGraph?: boolean; askImageCitation?: boolean; + lineageIsolationReason?: "comparison_candidates_available" | "no_comparison_group"; }): ReturnType & { releaseMe: () => void; releasePostOne: () => void } { const statusLabel: Record = { open: "Open", @@ -1058,6 +1059,16 @@ describe("App, authenticated", () => { return Promise.resolve(jsonResponse({ group_count: 1 })); } if (url.includes("/api/lineage") && method === "GET") { + if (options?.lineageIsolationReason) { + return Promise.resolve( + jsonResponse({ + nodes: [], + edges: [], + truncated: false, + isolation_reason: options.lineageIsolationReason, + }), + ); + } return Promise.resolve( jsonResponse({ nodes: [ @@ -1644,7 +1655,7 @@ describe("App, authenticated", () => { return Promise.resolve( jsonResponse({ post_id: "post-1", - direct: [ + direct: options?.lineageIsolationReason ? [] : [ { post_id: "rec-003", post_title: "Pricing renegotiation: revised quote sent", @@ -1653,7 +1664,9 @@ describe("App, authenticated", () => { interval_is_parent: true, }, ], - indirect: [{ post_id: "post-2", post_title: "Linked post" }], + indirect: options?.lineageIsolationReason + ? [] + : [{ post_id: "post-2", post_title: "Linked post" }], }), ); } @@ -2231,6 +2244,23 @@ describe("App, authenticated", () => { expect(screen.getByRole("dialog", { name: "Linked post" })).toHaveFocus(); }); + it.each([ + [ + "comparison_candidates_available" as const, + "Other visible posts share this comparison group, but no Event Lineage link is available. Read Keyman and evaluation next.", + ], + [ + "no_comparison_group" as const, + "No other visible posts share this comparison group yet. Request reconstruction after more posts arrive, or read Keyman and evaluation.", + ], + ])("explains an empty focused Event Lineage graph: %s", async (lineageIsolationReason, message) => { + stubBackend({ lineageIsolationReason }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + expect(await screen.findByText(message)).toBeInTheDocument(); + expect(screen.queryByText("No linked posts yet.")).not.toBeInTheDocument(); + }); + it("shows an embedded invoice image instead of the raw base64 string", async () => { const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e76a3b16a..7cd115c5c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -484,11 +484,17 @@ function EventLineageSection({ const scoped = graph ? subgraphForPost(graph, postId) : { nodes: [], edges: [] }; const hasLinks = lineage.direct.length > 0 || lineage.indirect.length > 0; if (scoped.nodes.length === 0) { + const isolationMessage = + graph.isolation_reason === "comparison_candidates_available" + ? t("Other visible posts share this comparison group, but no Event Lineage link is available. Read Keyman and evaluation next.") + : graph.isolation_reason === "no_comparison_group" + ? t("No other visible posts share this comparison group yet. Request reconstruction after more posts arrive, or read Keyman and evaluation.") + : t("No linked posts yet."); return (

{hasLinks ? t("The linked records are listed above. The graph is not available for this view.") - : t("No linked posts yet.")} + : isolationMessage}

); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bd15e003b..8772e2139 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -456,6 +456,7 @@ export interface LineageGraph { nodes: LineageGraphNode[]; edges: LineageGraphEdge[]; truncated?: boolean; + isolation_reason?: "comparison_candidates_available" | "no_comparison_group" | null; } export function fetchLineageGraph(accessToken: string, postId?: string): Promise { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 36e5878f9..3f4355d1f 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -219,6 +219,8 @@ const TRANSLATIONS: Partial>> = { "The linked records are listed above. The graph is not available for this view.": "연결된 기록은 위 목록에 있습니다. 이 화면에서는 그래프를 사용할 수 없습니다.", "No linked posts yet.": "아직 연결된 글이 없습니다.", + "Other visible posts share this comparison group, but no Event Lineage link is available. Read Keyman and evaluation next.": "다른 표시 가능한 글이 이 비교 그룹에 있지만 Event Lineage 연결은 없습니다. 다음으로 핵심 담당자와 평가를 확인하세요.", + "No other visible posts share this comparison group yet. Request reconstruction after more posts arrive, or read Keyman and evaluation.": "아직 이 비교 그룹을 공유하는 다른 표시 가능한 글이 없습니다. 글이 더 들어온 뒤 재구성을 요청하거나 핵심 담당자와 평가를 확인하세요.", "VOC evidence": "VOC 근거", "Affiliate tree": "소속 트리", Keymen: "핵심 담당자", @@ -698,6 +700,8 @@ const TRANSLATIONS: Partial>> = { "The linked records are listed above. The graph is not available for this view.": "关联记录列在上方。此视图暂时无法显示图谱。", "No linked posts yet.": "暂时没有关联文章。", + "Other visible posts share this comparison group, but no Event Lineage link is available. Read Keyman and evaluation next.": "此比较组中还有其他可见文章,但目前没有 Event Lineage 连接。接下来请查看关键人员和评估。", + "No other visible posts share this comparison group yet. Request reconstruction after more posts arrive, or read Keyman and evaluation.": "此比较组中暂时没有其他可见文章。请在更多文章到达后请求重建,或查看关键人员和评估。", "VOC evidence": "客户之声证据", "Affiliate tree": "关联组织树", Keymen: "关键联系人", @@ -1199,6 +1203,8 @@ const TRANSLATIONS: Partial>> = { "The linked records are listed above. The graph is not available for this view.": "関連記録は上に表示されています。この表示ではグラフを利用できません。", "No linked posts yet.": "関連する投稿はまだありません。", + "Other visible posts share this comparison group, but no Event Lineage link is available. Read Keyman and evaluation next.": "この比較グループには他の表示可能な投稿がありますが、Event Lineage のリンクはありません。次にキーパーソンと評価を確認してください。", + "No other visible posts share this comparison group yet. Request reconstruction after more posts arrive, or read Keyman and evaluation.": "この比較グループを共有する他の表示可能な投稿はまだありません。投稿が増えた後に再構築を依頼するか、キーパーソンと評価を確認してください。", "VOC evidence": "VOCの証拠", "Affiliate tree": "所属ツリー", Keymen: "キーパーソン", @@ -1677,6 +1683,8 @@ const TRANSLATIONS: Partial>> = { "The linked records are listed above. The graph is not available for this view.": "Các bản ghi liên quan nằm ở trên. Đồ thị không khả dụng trong chế độ xem này.", "No linked posts yet.": "Chưa có bài viết liên quan.", + "Other visible posts share this comparison group, but no Event Lineage link is available. Read Keyman and evaluation next.": "Có bài viết hiển thị khác trong nhóm so sánh này nhưng chưa có liên kết Event Lineage. Tiếp theo, hãy xem Keyman và phần đánh giá.", + "No other visible posts share this comparison group yet. Request reconstruction after more posts arrive, or read Keyman and evaluation.": "Chưa có bài viết hiển thị nào khác trong nhóm so sánh này. Hãy yêu cầu tái dựng sau khi có thêm bài viết, hoặc xem Keyman và phần đánh giá.", "VOC evidence": "Bằng chứng VOC", "Affiliate tree": "Cây liên kết", Keymen: "Người liên hệ chính",