Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/adr/0143-lineage-isolation-reason.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 32 additions & 2 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
organizationAliases?: boolean;
askLineageGraph?: boolean;
askImageCitation?: boolean;
lineageIsolationReason?: "comparison_candidates_available" | "no_comparison_group";
}): ReturnType<typeof vi.fn> & { releaseMe: () => void; releasePostOne: () => void } {
const statusLabel: Record<string, string> = {
open: "Open",
Expand Down Expand Up @@ -1058,6 +1059,16 @@
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: [
Expand Down Expand Up @@ -1644,7 +1655,7 @@
return Promise.resolve(
jsonResponse({
post_id: "post-1",
direct: [
direct: options?.lineageIsolationReason ? [] : [
{
post_id: "rec-003",
post_title: "Pricing renegotiation: revised quote sent",
Expand All @@ -1653,7 +1664,9 @@
interval_is_parent: true,
},
],
indirect: [{ post_id: "post-2", post_title: "Linked post" }],
indirect: options?.lineageIsolationReason
? []
: [{ post_id: "post-2", post_title: "Linked post" }],
}),
);
}
Expand Down Expand Up @@ -2215,7 +2228,7 @@

await userEvent.type(within(board).getByLabelText("Search semantic evidence"), "not found");
await userEvent.click(within(board).getByRole("button", { name: "Search" }));
expect(within(board).getByRole("status")).toHaveTextContent("No posts match the current filters.");

Check failure on line 2231 in frontend/src/App.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend lint, test, build

src/App.test.tsx > App, authenticated > renders the board landmark and functional post controls

TestingLibraryElementError: Found multiple elements with the role "status" Here are the matching elements: Ignored nodes: comments, script, style <p class="board-empty" role="status" > No posts match the current filters. </p> Ignored nodes: comments, script, style <p class="popup-placeholder" role="status" > 이 범위의 일정을 아직 받을 수 없습니다 </p> (If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or `findAllByText`)). Ignored nodes: comments, script, style <section aria-labelledby="board-title" class="board-surface" > <header class="board-header" > <div> <p class="post-meta" > Board </p> <h2 id="board-title" > Board </h2> <p> Authorized posts in this board. </p> </div> <p aria-live="polite" class="board-result-count" > Posts shown: 0 / 0 </p> </header> <form aria-label="Search and filter posts" class="board-controls" role="search" > <label> Search semantic evidence <input aria-label="Search semantic evidence" placeholder="Search semantic evidence" type="search" value="not found" /> </label> <button type="submit" > Search </button> <p class="board-search-help post-meta" > Search includes post text and semantic evidence. </p> <fieldset class="board-voc-type-filter" > <legend> Filter by VOC type </legend> </fieldset> <label> Filter by visibility <select aria-label="Filter by visibility" > <option value="all" > All visibility </option> </select> </label> <label> Sort posts <select aria-label="Sort posts" > <option value="newest" > Newest first </option> <option value="oldest" > Oldest first </option> <option value="title" > Title A-Z </option> </select> </label> <button class="board-reset" type="reset" > Reset filters </button> </form> <p class="board-empty" role="status" > No posts match the current filters. </p> <details class="advanced-review-tools" > <summary> Advanced review tools </summary> <section aria-labelledby="lab-calendar-heading" class="popup-section lineage-home" > <h2 id="lab-calendar-heading" > Calendar </h2> <section aria-labelledby="lab-calendar-heading-observed" class="popup-section" > <h3 id="lab-calendar-heading-observed" > Observed calendar events </h3> <p class="popup-placeholder" role="status" > 이 범위의 일정을 아직 받을 수 없습니다 </p> <p class="popup-placeholder" > Connect the Naruon calendar projection. Open a commitment below to read that post. </p> </section> <section aria-labelledby="lab-calendar-heading-commitments" class="popup-section" > <h3 id="lab-calendar-heading-commitments" > Upcoming commitments </h3> <ul class="ticket-list" > <li class="ticket-list-item" > <button aria-label="Open commitment for: Public post" class="post-list-item" type="button" > <span class="ticket-title" > Send Northridge Grid the revised quote </span> <span class="post-badge" > Public post </span
await userEvent.click(within(board).getByRole("button", { name: "Reset filters" }));
expect(within(board).getByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
});
Expand All @@ -2231,6 +2244,23 @@
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(<App showLabPanels />);
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=";
Expand Down Expand Up @@ -3375,7 +3405,7 @@
expect(
screen.getByRole("button", { name: "Compare Business unit (PU): Demo Report High, mean θ 0.81" }),
).not.toHaveAttribute("aria-current");
expect(screen.getByRole("status")).toHaveTextContent(

Check failure on line 3408 in frontend/src/App.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend lint, test, build

src/App.test.tsx > App, authenticated > does not tell a succeeded period report to rebuild, reconstruct, or measure

TestingLibraryElementError: Found multiple elements with the role "status" Here are the matching elements: Ignored nodes: comments, script, style <p class="popup-placeholder" role="status" > 이 범위의 일정을 아직 받을 수 없습니다 </p> Ignored nodes: comments, script, style <p class="post-meta" role="status" > Demo Corp is the opened grouping. Read its mean θ and member posts below, then open a post. </p> (If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or `findAllByText`)). Ignored nodes: comments, script, style <body> <div> <div class="app-shell" > <header class="app-header" > <div class="app-header-logo" > <h1 class="app-header-title" > LineageWeave </h1> </div> <div class="app-header-top-menu" > <span class="app-user-profile" > demo.analyst </span> <button class="btn-secondary" > Log out </button> </div> </header> <nav aria-label="Workspace navigation" class="workspace-gnb" > <button aria-current="page" class="workspace-gnb-item" type="button" > 게시판 </button> <button class="workspace-gnb-item" type="button" > 고객 마스터 </button> <button class="workspace-gnb-item" type="button" > 달력 </button> <button class="workspace-gnb-item" type="button" > Ask Agent </button> <div class="workspace-gnb-tools" > <label class="language-switcher" > <span class="visually-hidden" > Language </span> <select aria-label="Language" > <option value="en" > English </option> <option value="ko" > 한국어 </option> <option value="zh" > 中文 </option> <option value="ja" > 日本語 </option> <option value="vi" > Tiếng Việt </option> </select> </label> </div> </nav> <main> <section aria-labelledby="board-title" class="board-surface" > <header class="board-header" > <div> <p class="post-meta" > Board </p> <h2 id="board-title" > Board </h2> <p> Authorized posts in this board. </p> </div> <p aria-live="polite" class="board-result-count" > Posts shown: 1 / 1 </p> </header> <form aria-label="Search and filter posts" class="board-controls" role="search" > <label> Search semantic evidence <input aria-label="Search semantic evidence" placeholder="Search semantic evidence" type="search" value="" /> </label> <button type="submit" > Search </button> <p class="board-search-
"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();
Expand Down Expand Up @@ -3422,7 +3452,7 @@
expect(demoChip).toHaveAccessibleName(/mean θ 0\.42/);
expect(scrollIntoView).toHaveBeenCalled();
expect(periodInput).not.toHaveFocus();
expect(screen.getByRole("status")).toHaveTextContent(

Check failure on line 3455 in frontend/src/App.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend lint, test, build

src/App.test.tsx > App, authenticated > lands the comparison strip on Demo Corp when already on that week

TestingLibraryElementError: Found multiple elements with the role "status" Here are the matching elements: Ignored nodes: comments, script, style <p class="popup-placeholder" role="status" > 이 범위의 일정을 아직 받을 수 없습니다 </p> Ignored nodes: comments, script, style <p class="post-meta" role="status" > Demo Corp is the opened grouping. Read its mean θ and member posts below, then open a post. </p> (If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or `findAllByText`)). Ignored nodes: comments, script, style <body> <div> <div class="app-shell" > <header class="app-header" > <div class="app-header-logo" > <h1 class="app-header-title" > LineageWeave </h1> </div> <div class="app-header-top-menu" > <span class="app-user-profile" > demo.analyst </span> <button class="btn-secondary" > Log out </button> </div> </header> <nav aria-label="Workspace navigation" class="workspace-gnb" > <button aria-current="page" class="workspace-gnb-item" type="button" > 게시판 </button> <button class="workspace-gnb-item" type="button" > 고객 마스터 </button> <button class="workspace-gnb-item" type="button" > 달력 </button> <button class="workspace-gnb-item" type="button" > Ask Agent </button> <div class="workspace-gnb-tools" > <label class="language-switcher" > <span class="visually-hidden" > Language </span> <select aria-label="Language" > <option value="en" > English </option> <option value="ko" > 한국어 </option> <option value="zh" > 中文 </option> <option value="ja" > 日本語 </option> <option value="vi" > Tiếng Việt </option> </select> </label> </div> </nav> <main> <section aria-labelledby="board-title" class="board-surface" > <header class="board-header" > <div> <p class="post-meta" > Board </p> <h2 id="board-title" > Board </h2> <p> Authorized posts in this board. </p> </div> <p aria-live="polite" class="board-result-count" > Posts shown: 1 / 1 </p> </header> <form aria-label="Search and filter posts" class="board-controls" role="search" > <label> Search semantic evidence <input aria-label="Search semantic evidence" placeholder="Search semantic evidence" type="search" value="" /> </label> <button type="submit" > Search </button> <p class="board-search-
"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();
Expand Down Expand Up @@ -3928,7 +3958,7 @@
await userEvent.click(
screen.getByRole("button", { name: "Compare Thread group: A-100, mean θ 0.81" }),
);
expect(screen.getByRole("status")).toHaveTextContent(

Check failure on line 3961 in frontend/src/App.test.tsx

View workflow job for this annotation

GitHub Actions / Frontend lint, test, build

src/App.test.tsx > App, authenticated > shows the grouping comparison strip and switches grouping on click

TestingLibraryElementError: Found multiple elements with the role "status" Here are the matching elements: Ignored nodes: comments, script, style <p class="popup-placeholder" role="status" > 이 범위의 일정을 아직 받을 수 없습니다 </p> Ignored nodes: comments, script, style <p class="post-meta" role="status" > A-100 is the opened grouping. Read its mean θ and member posts below, then open a post. </p> (If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or `findAllByText`)). Ignored nodes: comments, script, style <body> <div> <div class="app-shell" > <header class="app-header" > <div class="app-header-logo" > <h1 class="app-header-title" > LineageWeave </h1> </div> <div class="app-header-top-menu" > <span class="app-user-profile" > demo.analyst </span> <button class="btn-secondary" > Log out </button> </div> </header> <nav aria-label="Workspace navigation" class="workspace-gnb" > <button aria-current="page" class="workspace-gnb-item" type="button" > 게시판 </button> <button class="workspace-gnb-item" type="button" > 고객 마스터 </button> <button class="workspace-gnb-item" type="button" > 달력 </button> <button class="workspace-gnb-item" type="button" > Ask Agent </button> <div class="workspace-gnb-tools" > <label class="language-switcher" > <span class="visually-hidden" > Language </span> <select aria-label="Language" > <option value="en" > English </option> <option value="ko" > 한국어 </option> <option value="zh" > 中文 </option> <option value="ja" > 日本語 </option> <option value="vi" > Tiếng Việt </option> </select> </label> </div> </nav> <main> <section aria-labelledby="board-title" class="board-surface" > <header class="board-header" > <div> <p class="post-meta" > Board </p> <h2 id="board-title" > Board </h2> <p> Authorized posts in this board. </p> </div> <p aria-live="polite" class="board-result-count" > Posts shown: 1 / 1 </p> </header> <form aria-label="Search and filter posts" class="board-controls" role="search" > <label> Search semantic evidence <input aria-label="Search semantic evidence" placeholder="Search semantic evidence" type="search" value="" /> </label> <button type="submit" > Search </button> <p class="board-search-help
"A-100 is the opened grouping. Read its mean θ and member posts below, then open a post.",
);
expect(
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Comment thread
seonghobae marked this conversation as resolved.
return (
<p className="lineage-empty">
{hasLinks
? t("The linked records are listed above. The graph is not available for this view.")
: t("No linked posts yet.")}
: isolationMessage}
</p>
);
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LineageGraph> {
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"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: "핵심 담당자",
Expand Down Expand Up @@ -698,6 +700,8 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"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: "关键联系人",
Expand Down Expand Up @@ -1199,6 +1203,8 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"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: "キーパーソン",
Expand Down Expand Up @@ -1677,6 +1683,8 @@ const TRANSLATIONS: Partial<Record<Locale, Record<string, string>>> = {
"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",
Expand Down
Loading