From 685eea68808bc2321ed4d182086a92cb24deeb2f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:50:06 +0000 Subject: [PATCH 1/2] feat: apply analysis-run knowledge cutoff to visible posts (v0.83.0) A later own-corp post no longer appears inside a historical run. Detail shows revision and configuration digest prefixes so an operator can confirm the run they approved. AnalysisRunsPanel is extracted for the Storybook inventory. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 7 +- .../0.83.0-analysis-run-cutoff-posts.md | 5 + CHANGELOG.md | 17 +++ CLAUDE.md | 8 + backend/app/analysis_run_ingestion.py | 21 ++- backend/tests/test_api.py | 25 +++- docs/adr/0014-authorized-analysis-run-read.md | 2 + ...016-analysis-run-knowledge-cutoff-posts.md | 58 +++++++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 8 + docs/storybook/INVENTORY.md | 30 ++++ .../plans/2026-08-15-analysis-run-registry.md | 10 +- frontend/package.json | 2 +- frontend/src/AnalysisRunsPanel.test.tsx | 35 +++++ frontend/src/AnalysisRunsPanel.tsx | 141 ++++++++++++++++++ frontend/src/App.css | 2 +- frontend/src/App.test.tsx | 5 + frontend/src/App.tsx | 126 +--------------- frontend/src/analysisRunDisplay.ts | 24 +++ frontend/src/api.ts | 3 + frontend/src/index.css | 6 + lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 23 files changed, 399 insertions(+), 142 deletions(-) create mode 100644 CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md create mode 100644 CLAUDE.md create mode 100644 docs/adr/0016-analysis-run-knowledge-cutoff-posts.md create mode 100644 docs/storybook/INVENTORY.md create mode 100644 frontend/src/AnalysisRunsPanel.test.tsx create mode 100644 frontend/src/AnalysisRunsPanel.tsx create mode 100644 frontend/src/analysisRunDisplay.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3f3a7cc9a..11cd837e8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -463,9 +463,12 @@ 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 the run +`knowledge_cutoff`, so a later post cannot appear inside a historical +reconstruction (ADR 0016). Detail also returns revision and +configuration digest prefixes. The home list is clickable: `GET /api/analysis-runs/{id}` fills a -labeled detail (cutoff, requested date, counts, status history) +labeled detail (cutoff, requested date, counts, status history, digests) 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 diff --git a/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md b/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md new file mode 100644 index 000000000..e1d4ccab6 --- /dev/null +++ b/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md @@ -0,0 +1,5 @@ +# 0.83.0 analysis-run cutoff-scoped posts + +Detail lists only posts known at the run knowledge cutoff and shows +revision/config digest prefixes so an operator can confirm the run +they approved. Later own-corp posts stay hidden. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac2df4ec..b7bd43c75 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.83.0] - 2026-08-16 + +### Fixed + +- Analysis-run detail now lists only posts whose `created_at` is at or + before the run `knowledge_cutoff`. After `make seed`, a later own-corp + post does not appear inside the January 12 reconstruction. Open a later + run, or ask an administrator to capture a newer snapshot, when the list + is empty. + +### Added + +- Run detail shows revision and configuration digest prefixes so an + operator can confirm the run matches the code they approved (ADR 0016). +- `AnalysisRunsPanel` is a standalone home-page module with a Storybook + inventory for the next frontend toolchain slice. + ## [0.82.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..27b9b7a13 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# CLAUDE.md + +Read [AGENTS.md](AGENTS.md) first. Product architecture lives in +[ARCHITECTURE.md](ARCHITECTURE.md). Active analysis-run decisions are +ADR 0013–0016 under `docs/adr/`. + +This repository ships synthetic data only. Do not add fixtures, tests, +or examples derived from a real organization's records. diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index c18ddf326..72c2c7de9 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -2,9 +2,10 @@ The registry itself is issue #89 / migration 0018. This module is the product projection: an account sees only runs they requested or whose -scope they already have ABAC authority to walk. Aggregate counts and -lookup labels come back; source SQL, DSNs, raw records, and provider -payloads never do. +scope they already have ABAC authority to walk. Detail post titles are +further limited to posts known at the run knowledge cutoff. Aggregate +counts and lookup labels come back; source SQL, DSNs, raw records, and +provider payloads never do. """ from __future__ import annotations @@ -226,6 +227,7 @@ async def fetch_visible_analysis_run( row["process_unit_id"], row["scope_key"], affiliated_entity_ids, + row["knowledge_cutoff"], ) return detail @@ -237,33 +239,42 @@ 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 titles known at the run cutoff -- 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 " + "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/tests/test_api.py b/backend/tests/test_api.py index cfc2a5559..1ec47b88f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -296,11 +296,18 @@ 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-10T09: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 +320,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") + _insert_post( + "Late own-corp private post", + own_corp_id, + "private", + "Written after the analysis-run knowledge cutoff.", + created_at="2026-01-13T09:00:00Z", + ) cur.execute( "insert into cataloged_person (person_name, person_side_code) values " @@ -477,7 +491,10 @@ 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 body["code_revision_sha"] == "c" * 40 + assert body["configuration_sha256"] == "b" * 64 assert "postgresql://" not in str(body) assert "visible_posts" not in visible diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index c0f32beef..6349ae596 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -31,6 +31,8 @@ LineageWeave owns a fail-closed read projection of the #89 registry: - `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. +- Detail post titles are further limited to + `source_post.created_at <= knowledge_cutoff` (ADR 0016). - 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 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 000000000..b918c3450 --- /dev/null +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -0,0 +1,58 @@ +# ADR 0016 — Analysis-run post lists apply the run knowledge cutoff + +**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; ADR 0014 authorized analysis-run read +**Refs:** Issue #79 (Milestone 2 parent); PR #89 registry + read projection + +## Context + +PR #89 stores `knowledge_cutoff` on each `analysis_run` and ADR 0013 +requires that a run may use only evidence available at that cutoff. +The v0.82 authorized detail listed every ABAC-visible post in the run +scope. A post written after the cutoff therefore appeared inside a +historical reconstruction. That is the buyer-visible temporal leak: +an operator cannot trust that "this run" is the evidence the run was +allowed to know. + +The registry already distinguishes snapshot availability from run +cutoff (Jensen & Snodgrass, 1999; W3C Time Ontology in OWL, 2022). +The read projection must apply the same as-of predicate when it +projects `source_post` titles. + +## Decision + +`GET /api/analysis-runs/{id}` includes a post title only when: + +1. the post is in the run's scope; +2. the caller already has ABAC authority to see that post; +3. `source_post.created_at <= analysis_run.knowledge_cutoff`. + +Hidden or later posts never appear. The list payload stays +aggregates-only. Detail also returns `code_revision_sha` and +`configuration_sha256` so an operator can confirm the run matches +the code and configuration they approved. Prefixes are shown in the +home panel; full digests remain on the API. + +The home Analysis runs panel lives in `AnalysisRunsPanel` so the +repeating list/detail object can be inventoried for Storybook without +growing `App.tsx`. + +## Consequences + +Fixture posts that belong in a January 2026 run must carry a +`created_at` at or before that cutoff. A later own-corp post is the +falsifiable counter-example. Write/rebuild APIs, TEPP submission, and +run-scoped post bodies remain later slices. + +## References + +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 + +Snodgrass, R. T. (Ed.). (1995). *The TSQL2 temporal query language*. +Springer. https://doi.org/10.1007/978-1-4615-2289-8 + +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 b439cb9ca..4dfeb8813 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -69,6 +69,7 @@ provenance, retention, and immutable evidence rather than blanket masking. |---|---| | 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. | +| Later posts are excluded from a historical run | A post with `created_at` after `knowledge_cutoff` is absent from `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. | @@ -82,6 +83,10 @@ 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 + 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 @@ -89,6 +94,9 @@ Technology. https://doi.org/10.6028/NIST.SP.800-92 Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ +Snodgrass, R. T. (Ed.). (1995). *The TSQL2 temporal query language*. +Springer. https://doi.org/10.1007/978-1-4615-2289-8 + OpenAPI Initiative. (2025). *OpenAPI specification, version 3.2.0*. https://spec.openapis.org/oas/v3.2.0.html diff --git a/docs/storybook/INVENTORY.md b/docs/storybook/INVENTORY.md new file mode 100644 index 000000000..98225be1b --- /dev/null +++ b/docs/storybook/INVENTORY.md @@ -0,0 +1,30 @@ +# Storybook inventory + +**Status:** Component inventory for the repeating home-page objects. +Storybook itself is the next frontend toolchain slice; do not add a +second Node package manager while adding it (`frontend/mise.toml` +pins Node 24, Corepack pnpm only). + +## Repeating objects + +| Object | Module | States a Storybook story must cover | +|---|---|---| +| Analysis run list | `frontend/src/AnalysisRunsPanel.tsx` | empty (`make seed` hint), loading, one succeeded Demo Corp run, hidden-run error | +| Analysis run detail | same | cutoff + requested date, status history, digest prefixes, in-cutoff posts, no posts at cutoff | +| Post list chip | `frontend/src/App.tsx` | public / private badges | +| Calendar commitment | `frontend/src/App.tsx` | dated open ticket | +| Period report row | `frontend/src/App.tsx` | mean θ, CAT item, member click-through | +| Post popup | `frontend/src/App.tsx` | summary, lineage, Keyman, tickets, chat | + +## Design tokens + +Repeating chips, badges, and list rows must use the CSS custom +properties in `frontend/src/index.css` (`--lw-space-*`, +`--lw-radius-*`, `--lw-color-danger`) rather than one-off hex values +when those objects are next extracted. + +## Next action + +Add Storybook via `pnpm` in `frontend/` with `@storybook/react-vite`, +then write CSF stories for `AnalysisRunsPanel` first. Keep stories on +synthetic Demo Corp fixtures only. diff --git a/docs/superpowers/plans/2026-08-15-analysis-run-registry.md b/docs/superpowers/plans/2026-08-15-analysis-run-registry.md index a3ed77d27..4cddaec1c 100644 --- a/docs/superpowers/plans/2026-08-15-analysis-run-registry.md +++ b/docs/superpowers/plans/2026-08-15-analysis-run-registry.md @@ -67,7 +67,15 @@ for Milestone 2 analysis requests and lifecycle evidence. 8. Obtain independent exact-head review and merge only after the parent PR is on protected `main` and base-sensitive evidence is regenerated. -## Task 5 — Next bounded vertical slice +## Task 5 — Knowledge-cutoff post projection (v0.83.0) + +1. Fail if a post written after `knowledge_cutoff` appears in + `visible_posts`. +2. Apply `created_at <= knowledge_cutoff` in the authorized read. +3. Show revision/config digest prefixes on the home detail. +4. Extract `AnalysisRunsPanel` and inventory Storybook states. + +## Task 6 — Next bounded vertical slice After this registry reaches protected main: diff --git a/frontend/package.json b/frontend/package.json index 5d3f2e2b8..d1e24268f 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/AnalysisRunsPanel.test.tsx b/frontend/src/AnalysisRunsPanel.test.tsx new file mode 100644 index 000000000..14d00e629 --- /dev/null +++ b/frontend/src/AnalysisRunsPanel.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { analysisRunCaption, shortDigest } from "./analysisRunDisplay"; +import type { AnalysisRun } from "./api"; + +const sampleRun: AnalysisRun = { + analysis_run_id: "run-demo-lineage", + 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:30:00Z", + source_counts: [], +}; + +describe("analysisRunCaption", () => { + it("joins kind, status, and scope so the operator knows which run to open", () => { + expect(analysisRunCaption(sampleRun)).toBe( + "Lineage reconstruction · Succeeded · Demo Corp", + ); + }); +}); + +describe("shortDigest", () => { + it("returns a 12-character prefix for comparing an approved revision", () => { + expect(shortDigest("c".repeat(40))).toBe("c".repeat(12)); + }); + + it("returns null when a digest is missing so the UI can hide the row", () => { + expect(shortDigest(undefined)).toBeNull(); + }); +}); diff --git a/frontend/src/AnalysisRunsPanel.tsx b/frontend/src/AnalysisRunsPanel.tsx new file mode 100644 index 000000000..28341a744 --- /dev/null +++ b/frontend/src/AnalysisRunsPanel.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import { + BackendError, + fetchAnalysisRun, + fetchAnalysisRuns, +} from "./api"; +import { analysisRunCaption, shortDigest } from "./analysisRunDisplay"; + +/** + * Home-page Analysis runs list and authorized detail. + * + * Click a run to load cutoff, lifecycle, reproducibility prefixes, and + * posts that existed at that cutoff. Hidden runs stay "not visible." + */ +export 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); + + useEffect(() => { + fetchAnalysisRuns(accessToken) + .then((payload) => setRuns(payload.analysis_runs)) + .catch((err) => setError(String(err))); + }, [accessToken]); + + async function handleOpen(runId: string) { + setError(null); + try { + setSelected(await fetchAnalysisRun(accessToken, runId)); + } catch (err) { + setSelected(null); + if (err instanceof BackendError && err.status === 404) { + setError("This analysis run is not visible."); + return; + } + setError(String(err)); + } + } + + if (error && runs === null) return

{error}

; + if (runs === null) return

Loading analysis runs...

; + + return ( +
+
+

Analysis runs

+
+ {error &&

{error}

} + {runs.length === 0 ? ( +

+ No analysis runs visible to this account yet -- try `make seed`. +

+ ) : ( +
    + {runs.map((run) => { + const documentCount = run.source_counts.find( + (count) => count.count_type_code === "analysis_count_document", + ); + const caption = analysisRunCaption(run); + return ( +
  • + +
  • + ); + })} +
+ )} + {selected && ( +
+

{analysisRunCaption(selected)}

+

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

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

+ Use these digests to confirm this run matches the code and configuration you approved. + {shortDigest(selected.code_revision_sha) && ` Revision ${shortDigest(selected.code_revision_sha)}`} + {shortDigest(selected.configuration_sha256) && ` · Config ${shortDigest(selected.configuration_sha256)}`} +

+ )} +
    + {selected.source_counts.map((count) => ( +
  • + {count.count_value} {count.count_type_label.toLowerCase()} +
  • + ))} +
+ {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. + ))} +
+ )} + {selected.visible_posts && selected.visible_posts.length > 0 ? ( +
    + {selected.visible_posts.map((post) => ( +
  • + +
  • + ))} +
+ ) : ( +

+ No posts were available at this cutoff. Open a later run, or ask an + administrator to capture a newer snapshot. +

+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/App.css b/frontend/src/App.css index dfd0f2e81..86f900a2b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -24,7 +24,7 @@ } .error { - color: #b91c1c; + color: var(--lw-color-danger); } .post-list { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 4fb1b5649..368ac70a4 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -190,6 +190,9 @@ describe("App, authenticated", () => { }, ], visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + configuration_schema_version: "lineage-run-v1", + configuration_sha256: "b".repeat(64), + code_revision_sha: "c".repeat(40), status_history: [ { status_ordinal: 1, @@ -1391,6 +1394,8 @@ describe("App, authenticated", () => { 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.getByText(/Revision cccccccccccc/)).toBeInTheDocument(); + expect(screen.getByText(/Config bbbbbbbbbbbb/)).toBeInTheDocument(); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Open run post: Public post" })); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1f350928d..095cc7346 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,8 +7,6 @@ import { deriveCommitment, evaluatePost, extractPostKeymen, - fetchAnalysisRun, - fetchAnalysisRuns, fetchCalendar, fetchLineageGraph, fetchMe, @@ -35,7 +33,6 @@ import { verifyPostRelations, type ActivityEvent, type AffiliateNode, - type AnalysisRun, type CalendarEntry, type ChatAnswer, type ChatExchange, @@ -55,6 +52,7 @@ import { type RelatedNode, type VocEvidence, } from "./api"; +import { AnalysisRunsPanel } from "./AnalysisRunsPanel"; import { LineageDag } from "./LineageDag"; import { subgraphForPost } from "./lineageLayout"; import "./App.css"; @@ -1347,128 +1345,6 @@ function PostDetailPopup({ ); } -function analysisRunCaption(run: AnalysisRun): string { - return [run.run_kind_label, run.status_label, run.scope_entity_name ?? run.scope_kind_label] - .filter(Boolean) - .join(" · "); -} - -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); - - useEffect(() => { - fetchAnalysisRuns(accessToken) - .then((payload) => setRuns(payload.analysis_runs)) - .catch((err) => setError(String(err))); - }, [accessToken]); - - async function handleOpen(runId: string) { - setError(null); - try { - setSelected(await fetchAnalysisRun(accessToken, runId)); - } catch (err) { - setSelected(null); - if (err instanceof BackendError && err.status === 404) { - setError("This analysis run is not visible."); - return; - } - setError(String(err)); - } - } - - if (error && runs === null) return

{error}

; - if (runs === null) return

Loading analysis runs...

; - - return ( -
-
-

Analysis runs

-
- {error &&

{error}

} - {runs.length === 0 ? ( -

- No analysis runs visible to this account yet -- try `make seed`. -

- ) : ( -
    - {runs.map((run) => { - const documentCount = run.source_counts.find( - (count) => count.count_type_code === "analysis_count_document", - ); - const caption = analysisRunCaption(run); - return ( -
  • - -
  • - ); - })} -
- )} - {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()} -
  • - ))} -
- {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. - ))} -
- )} - {selected.visible_posts && selected.visible_posts.length > 0 && ( -
    - {selected.visible_posts.map((post) => ( -
  • - -
  • - ))} -
- )} -
- )} -
- ); -} - function CalendarPanel({ accessToken, onSelectPost, diff --git a/frontend/src/analysisRunDisplay.ts b/frontend/src/analysisRunDisplay.ts new file mode 100644 index 000000000..6d5714f03 --- /dev/null +++ b/frontend/src/analysisRunDisplay.ts @@ -0,0 +1,24 @@ +import type { AnalysisRun } from "./api"; + +/** + * Buyer-facing caption for one authorized analysis run. + * + * Combines kind, latest status, and scope so the home list tells the + * operator which reconstruction they are about to open. + */ +export function analysisRunCaption(run: AnalysisRun): string { + return [run.run_kind_label, run.status_label, run.scope_entity_name ?? run.scope_kind_label] + .filter(Boolean) + .join(" · "); +} + +/** + * Shorten a reproducibility digest for the home detail. + * + * Full SHA values stay on the API payload; the UI shows enough prefix + * to compare against an approved revision without dumping a raw hash. + */ +export function shortDigest(value: string | undefined, length = 12): string | null { + if (!value) return null; + return value.slice(0, length); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3dacb054c..ea2f52c04 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -519,6 +519,9 @@ export interface AnalysisRun { knowledge_cutoff: string; requested_at: string; source_counts: AnalysisRunCount[]; + configuration_schema_version?: string; + configuration_sha256?: string; + code_revision_sha?: string; status_history?: AnalysisRunStatusEvent[]; visible_posts?: { post_id: string; post_title: string }[]; } diff --git a/frontend/src/index.css b/frontend/src/index.css index 5fb331302..df72732d8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -10,6 +10,12 @@ --social-bg: rgba(244, 243, 236, 0.5); --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + --lw-space-xs: 0.5rem; + --lw-space-sm: 0.75rem; + --lw-space-md: 1rem; + --lw-radius-sm: 8px; + --lw-radius-md: 12px; + --lw-color-danger: #b91c1c; --sans: system-ui, 'Segoe UI', Roboto, sans-serif; --heading: system-ui, 'Segoe UI', Roboto, sans-serif; diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index b1f0c97b1..5bd7638d6 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.82.0" +__version__ = "0.83.0" diff --git a/pyproject.toml b/pyproject.toml index 9238d87a2..f7b33f6ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.82.0" +version = "0.83.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 f3307cb32..06408c2a9 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.82.0" +version = "0.83.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From c2c8407912ca97bb0e789e55e982ac3ed32d15a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:03:42 +0900 Subject: [PATCH 2/2] fix: keep seeded Demo posts visible at the analysis-run cutoff Default created_at=now() hid every demo post from the January 12 run. Stamp Demo public/private at 2026-01-10, add Late Demo public post as the after-cutoff counter-example, and import AnalysisRun so tsc -b type-checks the extracted panel. --- .../0.83.0-analysis-run-cutoff-posts.md | 3 +- CHANGELOG.md | 8 +-- ...016-analysis-run-knowledge-cutoff-posts.md | 7 +-- frontend/src/AnalysisRunsPanel.test.tsx | 50 ++++++++++++++++++- frontend/src/AnalysisRunsPanel.tsx | 1 + scripts/seed_demo_data.py | 45 ++++++++++++++--- tests/test_analysis_run_authorization.py | 10 ++++ 7 files changed, 109 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md b/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md index e1d4ccab6..e470a5dfa 100644 --- a/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md +++ b/CHANGELOG.d/0.83.0-analysis-run-cutoff-posts.md @@ -2,4 +2,5 @@ Detail lists only posts known at the run knowledge cutoff and shows revision/config digest prefixes so an operator can confirm the run -they approved. Later own-corp posts stay hidden. +they approved. After `make seed`, Demo public post still opens; Late +Demo public post stays hidden. diff --git a/CHANGELOG.md b/CHANGELOG.md index b7bd43c75..42ac97268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,10 @@ All notable changes to this project are documented here. Format follows ### Fixed - Analysis-run detail now lists only posts whose `created_at` is at or - before the run `knowledge_cutoff`. After `make seed`, a later own-corp - post does not appear inside the January 12 reconstruction. Open a later - run, or ask an administrator to capture a newer snapshot, when the list - is empty. + before the run `knowledge_cutoff`. After `make seed`, Demo public post + (2026-01-10) still opens from the January 12 reconstruction; Late Demo + public post (2026-01-13) does not. Open a later run, or ask an + administrator to capture a newer snapshot, when the list is empty. ### Added diff --git a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md index b918c3450..ce4f3634d 100644 --- a/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md +++ b/docs/adr/0016-analysis-run-knowledge-cutoff-posts.md @@ -41,9 +41,10 @@ growing `App.tsx`. ## Consequences Fixture posts that belong in a January 2026 run must carry a -`created_at` at or before that cutoff. A later own-corp post is the -falsifiable counter-example. Write/rebuild APIs, TEPP submission, and -run-scoped post bodies remain later slices. +`created_at` at or before that cutoff. `make seed` stamps Demo public +and Demo private posts at 2026-01-10 and inserts Late Demo public post +at 2026-01-13 as the falsifiable own-corp counter-example. Write/rebuild +APIs, TEPP submission, and run-scoped post bodies remain later slices. ## References diff --git a/frontend/src/AnalysisRunsPanel.test.tsx b/frontend/src/AnalysisRunsPanel.test.tsx index 14d00e629..f96b4a42e 100644 --- a/frontend/src/AnalysisRunsPanel.test.tsx +++ b/frontend/src/AnalysisRunsPanel.test.tsx @@ -1,4 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AnalysisRunsPanel } from "./AnalysisRunsPanel"; import { analysisRunCaption, shortDigest } from "./analysisRunDisplay"; import type { AnalysisRun } from "./api"; @@ -33,3 +36,48 @@ describe("shortDigest", () => { expect(shortDigest(undefined)).toBeNull(); }); }); + +describe("AnalysisRunsPanel", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("tells the operator to open a later run when no posts existed at cutoff", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/analysis-runs/run-demo-lineage")) { + return Promise.resolve( + new Response( + JSON.stringify({ + ...sampleRun, + visible_posts: [], + code_revision_sha: "c".repeat(40), + configuration_sha256: "b".repeat(64), + }), + { status: 200 }, + ), + ); + } + if (url.endsWith("/api/analysis-runs")) { + return Promise.resolve( + new Response(JSON.stringify({ analysis_runs: [sampleRun] }), { status: 200 }), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }), + ); + + render( undefined} />); + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp", + }), + ); + expect( + await screen.findByText(/No posts were available at this cutoff/), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Open run post:/ })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/AnalysisRunsPanel.tsx b/frontend/src/AnalysisRunsPanel.tsx index 28341a744..2d68a3ce1 100644 --- a/frontend/src/AnalysisRunsPanel.tsx +++ b/frontend/src/AnalysisRunsPanel.tsx @@ -3,6 +3,7 @@ import { BackendError, fetchAnalysisRun, fetchAnalysisRuns, + type AnalysisRun, } from "./api"; import { analysisRunCaption, shortDigest } from "./analysisRunDisplay"; diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index dec4de9ad..70f118050 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -220,19 +220,52 @@ def seed( (account_id, roles[role_code]), ) + # Analysis-run knowledge cutoff is 2026-01-12T12:00:00Z. + # Default created_at=now() would hide every demo post from that run. + demo_post_created_at = "2026-01-10T09:00:00Z" + late_demo_post_created_at = "2026-01-13T09:00:00Z" 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) " + "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 public post', " "'Ada West at Demo Corp followed up with Priya Nair at Northridge Grid about the delayed shipment.', " - "'voc', 'public')", - (account_ids["demo.analyst"], corporate_entity_id, process_units["DEMO-PU-A"]), + "'voc', 'public', %s)", + ( + account_ids["demo.analyst"], + corporate_entity_id, + process_units["DEMO-PU-A"], + demo_post_created_at, + ), ) cur.execute( - "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code) " - "values (%s, %s, %s, 'Demo private post', 'A synthetic private post scoped to Demo Corp accounts.', 'vom', 'private')", - (account_ids["demo.admin"], corporate_entity_id, process_units["DEMO-PU-HQ"]), + "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', %s)", + ( + account_ids["demo.admin"], + corporate_entity_id, + process_units["DEMO-PU-HQ"], + demo_post_created_at, + ), + ) + cur.execute( + "update source_post set created_at = %s " + "where post_title in ('Demo public post', 'Demo private post')", + (demo_post_created_at,), + ) + cur.execute("select post_id from source_post where post_title = 'Late 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) " + "values (%s, %s, %s, 'Late Demo public post', " + "'Written after the Demo Corp lineage-run knowledge cutoff.', " + "'voc', 'public', %s)", + ( + account_ids["demo.analyst"], + corporate_entity_id, + process_units["DEMO-PU-A"], + late_demo_post_created_at, + ), ) cur.execute("select post_id from source_post where post_title = 'Demo public post'") diff --git a/tests/test_analysis_run_authorization.py b/tests/test_analysis_run_authorization.py index 730825c14..86b94fe56 100644 --- a/tests/test_analysis_run_authorization.py +++ b/tests/test_analysis_run_authorization.py @@ -14,6 +14,16 @@ _ROOT = Path(__file__).resolve().parents[1] _INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" _REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" +_SEED_SCRIPT = _ROOT / "scripts" / "seed_demo_data.py" + + +def test_seed_stamps_demo_posts_relative_to_run_cutoff() -> None: + """make seed must not hide Demo public post behind default created_at=now().""" + seed = _SEED_SCRIPT.read_text(encoding="utf-8") + assert "2026-01-12T12:00:00Z" in seed + assert "demo_post_created_at = \"2026-01-10T09:00:00Z\"" in seed + assert "late_demo_post_created_at = \"2026-01-13T09:00:00Z\"" in seed + assert "Late Demo public post" in seed _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" )