diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 33d9d2c09..3f3a7cc9a 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 000000000..1802a5644 --- /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 f9a1371a5..11c6c0e00 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 b9a09fc09..c18ddf326 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 3104dadd6..cfc2a5559 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 aacb6f74d..5d3f2e2b8 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 a06e0dc31..4fb1b5649 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 49b2bf16c..1f350928d 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 5dea72c0a..3dacb054c 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 5603c60fe..b1f0c97b1 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 3862da9a0..9238d87a2 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 2c009d7e1..f3307cb32 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" },