From ce4663c26a01296b15f4bb7a46ea96ae64f7c018 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:19:17 +0000 Subject: [PATCH 001/118] feat: name Weekly VOC and focus Event Lineage (v2.13.0) Weekly VOC keeps Voice of Customer posts for the latest ISO-8601 week. Opening that filtered post focuses Event Lineage. Home-list opens do not. --- AGENTS.md | 5 ++ ARCHITECTURE.md | 4 +- CHANGELOG.d/2.12.0-weekly-voc-iso-week.md | 5 ++ .../2.13.0-weekly-voc-open-event-lineage.md | 6 ++ CHANGELOG.md | 19 +++++ CLAUDE.md | 9 ++ docs/adr/0070-weekly-voc-iso-week-filter.md | 35 ++++++++ ...1-weekly-voc-open-focuses-event-lineage.md | 33 ++++++++ frontend/package.json | 2 +- frontend/src/App.css | 9 ++ frontend/src/App.test.tsx | 82 +++++++++++++++++++ frontend/src/App.tsx | 76 ++++++++++++++++- frontend/src/i18n.test.ts | 17 ++++ frontend/src/i18n.ts | 20 +++++ frontend/src/isoWeek.test.ts | 28 +++++++ frontend/src/isoWeek.ts | 30 +++++++ pyproject.toml | 2 +- 17 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.d/2.12.0-weekly-voc-iso-week.md create mode 100644 CHANGELOG.d/2.13.0-weekly-voc-open-event-lineage.md create mode 100644 docs/adr/0070-weekly-voc-iso-week-filter.md create mode 100644 docs/adr/0071-weekly-voc-open-focuses-event-lineage.md create mode 100644 frontend/src/isoWeek.test.ts create mode 100644 frontend/src/isoWeek.ts diff --git a/AGENTS.md b/AGENTS.md index 2b5c63795..38cd6d7b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,11 @@ adjudication does -- never a raw LLM API. Demo TEPP seed goes through envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`), never a fabricated theta or a local psychometric substitute. +Buyer Board **Weekly VOC** is an ISO-8601 week list filter (ADR 0070). +Opening that filtered post focuses Event Lineage (ADR 0071). Do not +invent a week, a theta, or a cutoff body. + + ## Tests ```bash diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aa8d256d6..510be0ad7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -279,7 +279,9 @@ Keycloak (`src/main.tsx`'s `AuthProvider`) -- no mocked auth, no static HTML. `src/api.ts` calls the FastAPI backend directly with the token Keycloak issued; `src/App.tsx` renders a git-branch SVG of `GET /api/lineage` (click a node to open that post; `post_admin` can -rebuild), the post list, and a full detail popup: Korean +rebuild), the post list with a named Weekly VOC ISO-8601 week filter +(ADR 0070; opening that filtered post focuses Event Lineage, ADR 0071), +and a full detail popup: Korean summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel (direct vs. indirect links; a link opens that post), the Keyman affiliate tree (resolved ancestors plus unresolved org roots), Keyman + diff --git a/CHANGELOG.d/2.12.0-weekly-voc-iso-week.md b/CHANGELOG.d/2.12.0-weekly-voc-iso-week.md new file mode 100644 index 000000000..5ce801c7c --- /dev/null +++ b/CHANGELOG.d/2.12.0-weekly-voc-iso-week.md @@ -0,0 +1,5 @@ +# 2.12.0 Weekly VOC ISO-week list filter + +Weekly VOC on Board keeps Voice of Customer posts for the latest ISO-8601 +week (UTC Thursday rule). Other VOC types and older weeks drop out. The +Board names Event Lineage as the next read. No TEPP theta is invented. diff --git a/CHANGELOG.d/2.13.0-weekly-voc-open-event-lineage.md b/CHANGELOG.d/2.13.0-weekly-voc-open-event-lineage.md new file mode 100644 index 000000000..7709c44cd --- /dev/null +++ b/CHANGELOG.d/2.13.0-weekly-voc-open-event-lineage.md @@ -0,0 +1,6 @@ +# 2.13.0 Opening Weekly VOC focuses Event Lineage + +Open a Voice of Customer post from an active Weekly VOC filter and the +popup Event Lineage heading takes focus. The popup names that post as +current and to read Keyman and evaluation next. Home-list opens do not. +No TEPP theta is invented. diff --git a/CHANGELOG.md b/CHANGELOG.md index f5719013a..31debb721 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). +## [2.13.0] - 2026-08-19 + +### Added + +- Opening a Voice of Customer post from an active Weekly VOC filter now + focuses Event Lineage and names Keyman and evaluation as the next read. + Home-list opens do not add that focus or copy. No TEPP theta is + invented. No cutoff body is invented (ADR 0071 / ADR 0016). + +## [2.12.0] - 2026-08-19 + +### Added + +- Board now names Weekly VOC as an ISO-8601 week list filter. The control + keeps Voice of Customer posts for the latest week present in the loaded + list (UTC Thursday rule) and tells the buyer to open a post to read + Event Lineage. Reset filters returns every VOC type and every week. + No TEPP theta is invented (ADR 0070). + ## [2.10.0] - 2026-08-18 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1bcf50763..e2aefa18b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,3 +70,12 @@ cited source. After that next action, the popup lands the first cited evidence. Changing the week first still focuses the report period field. Mean θ stays on the period-report panel. + +## Weekly VOC (v2.12.0 / v2.13.0) + +On Board, click **Weekly VOC**. Voice of Customer posts for the latest +ISO-8601 week stay; other VOC types and older weeks drop out. The Board +names Event Lineage as the next read (ADR 0070). Open a remaining post: +Event Lineage takes focus and names Keyman and evaluation next +(ADR 0071). A home-list open does not. Do not invent a theta. + diff --git a/docs/adr/0070-weekly-voc-iso-week-filter.md b/docs/adr/0070-weekly-voc-iso-week-filter.md new file mode 100644 index 000000000..b4ad265d8 --- /dev/null +++ b/docs/adr/0070-weekly-voc-iso-week-filter.md @@ -0,0 +1,35 @@ +# ADR 0070: Weekly VOC is an ISO-8601 week list filter + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +Board already exposes checkbox VOC-type filters (ADR 0060). Buyers still need +one named control that shows this week's Voice of Customer posts without +inventing a measurement or collapsing other VOC types into a guessed default. +PR #259 stacked a `` remains available beside the named + control. Reset filters returns both the checkboxes and the week to All. +- The Board names the next action: Voice of Customer posts for that week + are current; open a post to read Event Lineage. +- No TEPP theta is invented. No cutoff body is invented (ADR 0016). + +## Consequences + +- Weekly VOC composes with the existing checkbox VOC vocabulary instead of + replacing it. +- Posts whose `created_at` cannot be parsed contribute no week and cannot + be selected by this filter. +- Home-list and Customer-master opens are unchanged until ADR 0071. diff --git a/docs/adr/0071-weekly-voc-open-focuses-event-lineage.md b/docs/adr/0071-weekly-voc-open-focuses-event-lineage.md new file mode 100644 index 000000000..3aa3263ca --- /dev/null +++ b/docs/adr/0071-weekly-voc-open-focuses-event-lineage.md @@ -0,0 +1,33 @@ +# ADR 0071: Opening a Weekly VOC post focuses Event Lineage + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +ADR 0070 names Weekly VOC as an ISO-week list filter and tells the buyer to +open a post to read Event Lineage. Report-member opens already focus the +popup Event Lineage heading. A home-list open must not steal that focus or +add that next-action copy. + +## Decision + +Opening a Board post while Weekly VOC is active (`voc` only and a concrete +ISO week) is a `fromWeeklyVoc` open. That open reuses the existing Event +Lineage focus path used by report-member opens: + +- The popup Event Lineage heading takes focus. +- The popup names the opened post as current in Event Lineage and tells + the buyer to read Keyman and evaluation next. + +A Board open from the unfiltered home list, a reset filter list, or any +path that did not set `fromWeeklyVoc` does not focus Event Lineage and +does not add that copy. Closing the popup clears the Weekly VOC open flag. + +No TEPP theta is invented. No cutoff body is invented (ADR 0016). + +## Consequences + +- Weekly VOC and report-member opens share one focus contract. +- Changing VOC checkboxes or the ISO week so Weekly VOC is no longer + active makes the next Board open a home-list open. diff --git a/frontend/package.json b/frontend/package.json index a3e185542..20fda0d5f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.11.0", + "version": "2.13.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 6a9806f00..19b5d0159 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -145,6 +145,15 @@ min-height: 0; } +.board-weekly-voc[aria-pressed="true"] { + border-color: var(--text); + font-weight: 700; +} + +.board-next-action { + margin: 0 0 1rem; +} + .board-controls input, .board-controls select { min-height: var(--size-control-min); diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 27df706ec..9c43c49ee 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -78,6 +78,15 @@ describe("App, authenticated", () => { deferMe?: boolean; meFailed?: boolean; postBody?: string; + boardPosts?: { + post_id: string; + post_title: string; + voc_type_code: string; + voc_type_label?: string; + visibility_code?: string; + visibility_label?: string; + created_at: string; + }[]; }): ReturnType & { releaseMe: () => void } { const statusLabel: Record = { open: "Open", @@ -1028,6 +1037,7 @@ describe("App, authenticated", () => { visibility_label: "Public", created_at: "2026-01-01T00:00:00Z", }, + ...(options?.boardPosts ?? []), ], ), ); @@ -1523,6 +1533,78 @@ describe("App, authenticated", () => { expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); }); + it("clicking Weekly VOC keeps the 2026-W01 Voice of Customer post and names Event Lineage as the next action", async () => { + stubBackend({ + boardPosts: [ + { + post_id: "post-vom-w01", + post_title: "Internal memo", + voc_type_code: "vom", + voc_type_label: "Voice of Market", + visibility_code: "internal", + visibility_label: "Internal", + created_at: "2026-01-02T00:00:00Z", + }, + { + post_id: "post-voc-w52", + post_title: "Older Voice of Customer", + voc_type_code: "voc", + voc_type_label: "Voice of Customer", + visibility_code: "public", + visibility_label: "Public", + created_at: "2025-12-22T00:00:00Z", + }, + ], + }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + expect(within(board).getByRole("button", { name: "View post: Internal memo" })).toBeInTheDocument(); + expect(within(board).getByRole("button", { name: "View post: Older Voice of Customer" })).toBeInTheDocument(); + + const weeklyVoc = within(board).getByRole("button", { name: "Weekly VOC" }); + expect(weeklyVoc).toHaveAttribute("aria-pressed", "false"); + await userEvent.click(weeklyVoc); + + expect(weeklyVoc).toHaveAttribute("aria-pressed", "true"); + expect(within(board).getByLabelText("Filter by ISO week")).toHaveValue("2026-W01"); + expect(within(board).getByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + expect(within(board).queryByRole("button", { name: "View post: Internal memo" })).not.toBeInTheDocument(); + expect( + within(board).queryByRole("button", { name: "View post: Older Voice of Customer" }), + ).not.toBeInTheDocument(); + expect(within(board).getByLabelText("Next action")).toHaveTextContent( + "Voice of Customer posts for 2026-W01 are current. Open a post to read Event Lineage.", + ); + + await userEvent.click(within(board).getByRole("button", { name: "Reset filters" })); + expect(weeklyVoc).toHaveAttribute("aria-pressed", "false"); + expect(within(board).getByRole("button", { name: "View post: Internal memo" })).toBeInTheDocument(); + expect(within(board).getByRole("button", { name: "View post: Older Voice of Customer" })).toBeInTheDocument(); + }); + + it("opening a Weekly VOC post focuses Event Lineage; a home list open does not", async () => { + stubBackend(); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + await userEvent.click(within(board).getByRole("button", { name: "Weekly VOC" })); + await userEvent.click(within(board).getByRole("button", { name: "View post: Public post" })); + + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(document.getElementById("post-event-lineage")).toHaveFocus(); + expect(screen.getByRole("status", { name: "Event Lineage next action" })).toHaveTextContent( + "Public post is current in Event Lineage. Read Keyman and evaluation next.", + ); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click(within(board).getByRole("button", { name: "Reset filters" })); + await userEvent.click(within(board).getByRole("button", { name: "View post: Public post" })); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(document.getElementById("post-event-lineage")).not.toHaveFocus(); + expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); + }); + it("renders the A-100 fork as a git-style DAG, not a flat edge list", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 606164e27..f4233eab0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -93,6 +93,7 @@ import { tf, useLocale, } from "./i18n"; +import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -2396,6 +2397,7 @@ type SelectPostOptions = { liveAfterCutoff?: boolean; knowledgeCutoff?: string; fromReportMember?: boolean; + fromWeeklyVoc?: boolean; }; /** @@ -3456,6 +3458,7 @@ function PostList({ const [openedGroupingLabel, setOpenedGroupingLabel] = useState(null); const [landOnComparison, setLandOnComparison] = useState(false); const [openedFromReportMember, setOpenedFromReportMember] = useState(false); + const [openedFromWeeklyVoc, setOpenedFromWeeklyVoc] = useState(false); const [corporateEntities, setCorporateEntities] = useState(null); const [entitiesLoadError, setEntitiesLoadError] = useState(null); const [totalPosts, setTotalPosts] = useState(0); @@ -3464,6 +3467,7 @@ function PostList({ const [searchInput, setSearchInput] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const [typeFilter, setTypeFilter] = useState([]); + const [weekFilter, setWeekFilter] = useState("all"); const [visibilityFilter, setVisibilityFilter] = useState("all"); const [visibilityFilterOptions, setVisibilityFilterOptions] = useState([]); const [sortOrder, setSortOrder] = useState("newest"); @@ -3505,6 +3509,7 @@ function PostList({ setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); setOpenedCutoffIso(options?.knowledgeCutoff ?? null); setOpenedFromReportMember(Boolean(options?.fromReportMember)); + setOpenedFromWeeklyVoc(Boolean(options?.fromWeeklyVoc)); } useEffect(() => { @@ -3518,6 +3523,7 @@ function PostList({ setOpenedAfterCutoff(false); setOpenedCutoffIso(null); setOpenedFromReportMember(false); + setOpenedFromWeeklyVoc(false); const url = new URL(window.location.href); if (url.searchParams.has("post")) { url.searchParams.delete("post"); @@ -3603,7 +3609,9 @@ function PostList({ .filter((post) => { const matchesType = typeFilter.length === 0 || typeFilter.includes(post.voc_type_code); const matchesVisibility = visibilityFilter === "all" || post.visibility_code === visibilityFilter; - return matchesType && matchesVisibility; + const matchesWeek = + weekFilter === "all" || isoWeekFromCreatedAt(post.created_at) === weekFilter; + return matchesType && matchesVisibility && matchesWeek; }) .sort((left, right) => { if (sortOrder === "title") { @@ -3612,7 +3620,30 @@ function PostList({ const direction = sortOrder === "newest" ? -1 : 1; return direction * left.created_at.localeCompare(right.created_at); }); - const hasBoardFilters = Boolean(searchInput.trim()) || Boolean(searchQuery) || typeFilter.length > 0 || visibilityFilter !== "all"; + const weeklyVocActive = + typeFilter.length === 1 && typeFilter[0] === "voc" && weekFilter !== "all"; + const weekOptions = Array.from( + new Set( + loadedPosts + .map((post) => isoWeekFromCreatedAt(post.created_at)) + .filter((week): week is string => Boolean(week)), + ), + ).sort((left, right) => right.localeCompare(left)); + const applyWeeklyVoc = () => { + const vocWeek = latestIsoWeek( + loadedPosts + .filter((post) => post.voc_type_code === "voc") + .map((post) => isoWeekFromCreatedAt(post.created_at)), + ); + setTypeFilter(["voc"]); + setWeekFilter(vocWeek ?? "all"); + }; + const hasBoardFilters = + Boolean(searchInput.trim()) || + Boolean(searchQuery) || + typeFilter.length > 0 || + visibilityFilter !== "all" || + weekFilter !== "all"; const totalPages = Math.max(1, Math.ceil(totalPosts / POST_PAGE_SIZE)); const pageItems: Array = totalPages <= 7 @@ -3662,6 +3693,7 @@ function PostList({ setSearchInput(""); setSearchQuery(""); setTypeFilter([]); + setWeekFilter("all"); setVisibilityFilter("all"); setSortOrder("newest"); }} @@ -3697,6 +3729,29 @@ function PostList({ ))} + + + ) : null} + {projection ? : null} + + ); +} + export default function App({ showLabPanels = false }: { showLabPanels?: boolean } = {}) { useLocale(); const auth = useAuth(); const [destination, setDestination] = useState("board"); + const [projectHistoryKey, setProjectHistoryKey] = useState(null); const [postToOpen, setPostToOpen] = useState(() => { if (typeof window === "undefined") return null; return new URLSearchParams(window.location.search).get("post"); @@ -4785,6 +4902,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean const [postOpenFromCalendar, setPostOpenFromCalendar] = useState(false); const [postOpenFromCustomerMaster, setPostOpenFromCustomerMaster] = useState(false); const [postOpenFromAskAgent, setPostOpenFromAskAgent] = useState(false); + const [postOpenFromProjectHistory, setPostOpenFromProjectHistory] = useState(false); // Test-only compatibility for legacy analysis-panel coverage; this prop // never forces the panels open outside Vitest. In a real build the // advanced-review section (ADR 0037) is gated on PostList's own @@ -4852,7 +4970,10 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean { + if (nextDestination === "project-history") setProjectHistoryKey(null); + setDestination(nextDestination); + }} tools={} /> {destination === "board" ? ( @@ -4863,11 +4984,17 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean postOpenFromCalendar={postOpenFromCalendar} postOpenFromCustomerMaster={postOpenFromCustomerMaster} postOpenFromAskAgent={postOpenFromAskAgent} + postOpenFromProjectHistory={postOpenFromProjectHistory} onPostOpened={() => { setPostToOpen(null); setPostOpenFromCalendar(false); setPostOpenFromCustomerMaster(false); setPostOpenFromAskAgent(false); + setPostOpenFromProjectHistory(false); + }} + onOpenProjectHistory={(projectKey) => { + setProjectHistoryKey(projectKey); + setDestination("project-history"); }} /> ) : null} @@ -4908,6 +5035,20 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean }} /> ) : null} + {destination === "project-history" ? ( + { + setPostToOpen(postId); + setPostOpenFromCalendar(false); + setPostOpenFromCustomerMaster(false); + setPostOpenFromAskAgent(false); + setPostOpenFromProjectHistory(true); + setDestination("board"); + }} + /> + ) : null} ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3410993c5..e0f46dad5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -216,6 +216,17 @@ export interface ProjectEvidence { provenance: string; } +export interface ProjectHistoryIndexItem { + project_key: string; + project_name: string; + event_count: number; +} + +export interface ProjectHistoryIndexResponse { + knowledge_cutoff: string; + projects: ProjectHistoryIndexItem[]; +} + export interface PostAiSummary { post_id: string; korean_summary: string; @@ -419,6 +430,24 @@ export function fetchLineageGraph(accessToken: string, postId?: string): Promise return backendFetch(`/api/lineage${query}`, accessToken); } +export function fetchProjectHistoryIndex( + accessToken: string, +): Promise { + return backendFetch("/api/project-history/projects", accessToken); +} + +export function fetchProjectHistory( + accessToken: string, + projectKey: string, + knowledgeCutoff?: string, + focusPostId?: string, +): Promise { + const query = new URLSearchParams({ project_key: projectKey }); + if (knowledgeCutoff) query.set("knowledge_cutoff", knowledgeCutoff); + if (focusPostId) query.set("focus_post_id", focusPostId); + return backendFetch(`/api/project-history?${query.toString()}`, accessToken); +} + export interface CorporateEntityRef { corporate_entity_id: string; entity_name: string; diff --git a/frontend/src/components/BuyerNav.test.tsx b/frontend/src/components/BuyerNav.test.tsx index 21c7995fb..b6efc4aa5 100644 --- a/frontend/src/components/BuyerNav.test.tsx +++ b/frontend/src/components/BuyerNav.test.tsx @@ -3,12 +3,13 @@ import { describe, expect, it, vi } from "vitest"; import { BuyerNav } from "./BuyerNav"; describe("BuyerNav", () => { - it("renders the four buyer destinations and marks the current page", () => { + it("renders the five buyer destinations and marks the current page", () => { render(); expect(screen.getByRole("navigation")).toHaveAccessibleName("Buyer navigation"); expect(screen.getByRole("button", { name: "Board" })).toHaveAttribute("aria-current", "page"); expect(screen.getByRole("button", { name: "Customer master" })).not.toHaveAttribute("aria-current"); + expect(screen.getByRole("button", { name: "Project history" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Calendar" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Ask Agent" })).toBeInTheDocument(); }); diff --git a/frontend/src/components/BuyerNav.tsx b/frontend/src/components/BuyerNav.tsx index 4b5bddc92..f087cb133 100644 --- a/frontend/src/components/BuyerNav.tsx +++ b/frontend/src/components/BuyerNav.tsx @@ -1,7 +1,7 @@ import { t } from "../i18n"; import type { ReactNode } from "react"; -export type BuyerDestination = "board" | "customers" | "calendar" | "ask"; +export type BuyerDestination = "board" | "customers" | "calendar" | "ask" | "project-history"; export type BuyerNavProps = { destination: BuyerDestination; @@ -9,11 +9,12 @@ export type BuyerNavProps = { tools?: ReactNode; }; -const ITEMS: BuyerDestination[] = ["board", "customers", "calendar", "ask"]; +const ITEMS: BuyerDestination[] = ["board", "customers", "project-history", "calendar", "ask"]; const LABELS: Record = { board: "Board", customers: "Customer master", + "project-history": "Project history", calendar: "Calendar", ask: "Ask Agent", }; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 393aaa673..e69bd1cc3 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -147,6 +147,13 @@ const TRANSLATIONS: Partial>> = { "Board posts": "게시판 글", "No posts match the current filters.": "현재 필터에 맞는 글이 없습니다.", "Customer master": "고객 마스터", + "Project history": "프로젝트 이력", + "Authorized project evidence": "권한이 있는 프로젝트 근거", + "Select an exact project identity to read its visible history.": "정확한 프로젝트 식별자를 선택하여 공개 가능한 이력을 읽으세요.", + "Loading project history...": "프로젝트 이력을 불러오는 중...", + "No authorized project evidence is available.": "권한이 있는 프로젝트 근거가 없습니다.", + "Open project history": "프로젝트 이력 열기", + "Open project history for: {name}": "{name} 프로젝트 이력 열기", "Ask Agent": "Ask Agent", "Buyer navigation": "구매자 메뉴", "Authorized customer scope": "권한이 있는 고객 범위", @@ -489,6 +496,13 @@ const TRANSLATIONS: Partial>> = { "Board posts": "看板文章", "No posts match the current filters.": "没有文章符合当前筛选条件。", "Customer master": "客户主数据", + "Project history": "项目历史", + "Authorized project evidence": "已授权的项目依据", + "Select an exact project identity to read its visible history.": "选择准确的项目身份以查看可见历史。", + "Loading project history...": "正在加载项目历史…", + "No authorized project evidence is available.": "没有可用的已授权项目依据。", + "Open project history": "打开项目历史", + "Open project history for: {name}": "打开{name}的项目历史", "Ask Agent": "Ask Agent", "Buyer navigation": "买家导航", "Authorized customer scope": "已授权的客户范围", @@ -854,6 +868,13 @@ const TRANSLATIONS: Partial>> = { "Board posts": "掲示板の投稿", "No posts match the current filters.": "現在の絞り込みに一致する投稿はありません。", "Customer master": "顧客マスター", + "Project history": "プロジェクト履歴", + "Authorized project evidence": "認証済みプロジェクト証拠", + "Select an exact project identity to read its visible history.": "正確なプロジェクト識別子を選び、表示可能な履歴を読みます。", + "Loading project history...": "プロジェクト履歴を読み込み中…", + "No authorized project evidence is available.": "利用可能な認証済みプロジェクト証拠はありません。", + "Open project history": "プロジェクト履歴を開く", + "Open project history for: {name}": "{name}のプロジェクト履歴を開く", "Ask Agent": "Ask Agent", "Buyer navigation": "購入者ナビゲーション", "Authorized customer scope": "許可された顧客範囲", @@ -1195,6 +1216,13 @@ const TRANSLATIONS: Partial>> = { "Board posts": "Bài viết trên bảng tin", "No posts match the current filters.": "Không có bài viết nào khớp với bộ lọc hiện tại.", "Customer master": "Danh mục khách hàng", + "Project history": "Lịch sử dự án", + "Authorized project evidence": "Bằng chứng dự án được cấp quyền", + "Select an exact project identity to read its visible history.": "Chọn đúng danh tính dự án để đọc lịch sử có thể xem.", + "Loading project history...": "Đang tải lịch sử dự án…", + "No authorized project evidence is available.": "Không có bằng chứng dự án được cấp quyền.", + "Open project history": "Mở lịch sử dự án", + "Open project history for: {name}": "Mở lịch sử dự án của {name}", "Ask Agent": "Ask Agent", "Buyer navigation": "Điều hướng người mua", "Authorized customer scope": "Phạm vi khách hàng được cấp quyền", diff --git a/tests/test_project_history.py b/tests/test_project_history.py index 5e731585f..de57e26da 100644 --- a/tests/test_project_history.py +++ b/tests/test_project_history.py @@ -1,9 +1,17 @@ -"""RED contracts for the Buyer project-history timeline.""" +"""Contracts for the Buyer project-history timeline.""" from __future__ import annotations +import asyncio +from datetime import datetime, timezone + import pytest +from backend.app.project_history import ( + ProjectHistoryNotFound, + fetch_project_history_index, + fetch_project_history_projection, +) from lineageweave.project_history import ( classify_project_event, normalize_project_key, @@ -11,6 +19,50 @@ ) +class _IndexConnection: + """Return an aggregate index row without needing a live database.""" + + def __init__(self) -> None: + self.query = "" + self.args: tuple[object, ...] = () + + async def fetch(self, query: str, *args: object): + """Capture the bounded query and return one synthetic index row.""" + self.query = query + self.args = args + return [{"project_key": "p-100", "project_name": "Project 100", "event_count": 4}] + + +class _ProjectionConnection: + """Return bounded synthetic rows for the projection query sequence.""" + + def __init__(self, events, focus=()): + self.events = list(events) + self.focus = list(focus) + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + async def fetch(self, query: str, *args: object): + """Serve the event, optional focus, and three child reads in order.""" + self.calls.append((query, args)) + if len(self.calls) == 1: + return self.events + if "post_id = $4::uuid" in query: + return self.focus + return [] + + +def _event(post_id: str, day: int) -> dict[str, object]: + """Build one anonymous source event for projection contract tests.""" + return { + "post_id": post_id, + "post_title": f"Synthetic event {day}", + "created_at": datetime(2026, 1, day, tzinfo=timezone.utc), + "voc_type_code": None, + "source_stage_code": None, + "source_detail_state_code": None, + } + + def test_project_identity_is_exact_but_unicode_compatible() -> None: """Compatibility forms may normalize; fuzzy project binding may not.""" assert normalize_project_key(" P-100 ") == "p-100" @@ -49,3 +101,127 @@ def test_responsibility_transition_describes_document_evidence_only() -> None: assert responsibility_transition_code(["person:a"], ["person:a"]) == "continuous" assert responsibility_transition_code(["person:a"], ["person:b"]) == "handoff" assert responsibility_transition_code(["person:a"], []) == "assignment_gap" + + +def test_project_history_index_is_bounded_and_uses_source_name_priority() -> None: + connection = _IndexConnection() + result = asyncio.run( + fetch_project_history_index( + connection, + knowledge_cutoff=datetime(2026, 1, 1, tzinfo=timezone.utc), + corporate_entity_ids=["corp-1"], + limit=8, + ) + ) + + assert result == [{"project_key": "p-100", "project_name": "Project 100", "event_count": 4}] + assert connection.args[-1] == 8 + assert "array_agg(project_name order by display_priority" in connection.query + assert "nullif(btrim(post.source_project_code), '')" in connection.query + with pytest.raises(ValueError): + asyncio.run( + fetch_project_history_index( + connection, + knowledge_cutoff=datetime.now(timezone.utc), + corporate_entity_ids=[], + limit=129, + ) + ) + + +def test_project_history_projection_reads_children_and_keeps_visible_focus() -> None: + """An in-page focus uses only the already selected visible event IDs.""" + first = _event("00000000-0000-0000-0000-000000000001", 1) + second = _event("00000000-0000-0000-0000-000000000002", 2) + connection = _ProjectionConnection([first, second]) + result = asyncio.run( + fetch_project_history_projection( + connection, + project_key="P-100", + focus_post_id=str(first["post_id"]), + knowledge_cutoff=datetime.now(timezone.utc), + corporate_entity_ids=["corp-1"], + limit=2, + ) + ) + + assert result["focus_event_id"] == str(first["post_id"]) + assert result["event_count"] == 2 + assert len(connection.calls) == 4 + assert connection.calls[0][1][-1] == 3 + + +def test_project_history_projection_appends_authorized_focus_beyond_page() -> None: + """A focused event beyond a page is fetched and retained deterministically.""" + first = _event("00000000-0000-0000-0000-000000000001", 1) + second = _event("00000000-0000-0000-0000-000000000002", 2) + third = _event("00000000-0000-0000-0000-000000000003", 3) + connection = _ProjectionConnection([first, second, third], [third]) + result = asyncio.run( + fetch_project_history_projection( + connection, + project_key="P-100", + focus_post_id=str(third["post_id"]), + knowledge_cutoff=datetime.now(timezone.utc), + corporate_entity_ids=[], + limit=2, + ) + ) + + assert result["truncated"] is True + assert [event["source_post_id"] for event in result["events"]] == [ + str(first["post_id"]), + str(third["post_id"]), + ] + assert len(connection.calls) == 5 + + +def test_project_history_projection_focus_page_one_and_empty_cases_fail_closed() -> None: + """The one-row page and missing authorized evidence remain bounded and explicit.""" + first = _event("00000000-0000-0000-0000-000000000001", 1) + focus = _event("00000000-0000-0000-0000-000000000099", 2) + connection = _ProjectionConnection([first], [focus]) + result = asyncio.run( + fetch_project_history_projection( + connection, + project_key="P-100", + focus_post_id="00000000-0000-0000-0000-000000000099", + knowledge_cutoff=datetime.now(timezone.utc), + corporate_entity_ids=[], + limit=1, + ) + ) + assert result["events"][0]["source_post_id"] == str(focus["post_id"]) + + with pytest.raises(ProjectHistoryNotFound): + asyncio.run( + fetch_project_history_projection( + _ProjectionConnection([]), + project_key="P-100", + focus_post_id=None, + knowledge_cutoff=datetime.now(timezone.utc), + corporate_entity_ids=[], + ) + ) + with pytest.raises(ProjectHistoryNotFound): + asyncio.run( + fetch_project_history_projection( + _ProjectionConnection([first], []), + project_key="P-100", + focus_post_id="00000000-0000-0000-0000-000000000099", + knowledge_cutoff=datetime.now(timezone.utc), + corporate_entity_ids=[], + limit=1, + ) + ) + with pytest.raises(ValueError): + asyncio.run( + fetch_project_history_projection( + connection, + project_key="P-100", + focus_post_id=None, + knowledge_cutoff=datetime.now(timezone.utc), + corporate_entity_ids=[], + limit=0, + ) + ) From de07aad8fd50111790386121aac3c645c24358df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 03:12:06 -0700 Subject: [PATCH 106/118] test(projects): distinguish observed and inferred responsibility evidence --- .../ProjectHistoryTimeline.test.tsx | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/ProjectHistoryTimeline.test.tsx b/frontend/src/components/ProjectHistoryTimeline.test.tsx index 7d2f56197..ec626620c 100644 --- a/frontend/src/components/ProjectHistoryTimeline.test.tsx +++ b/frontend/src/components/ProjectHistoryTimeline.test.tsx @@ -11,8 +11,11 @@ const projection: ProjectHistoryProjection = { project_name: "Transformer renewal", focus_event_id: "voc", time_basis_code: "source_post_created_at_fallback", + knowledge_cutoff: "2026-08-20T00:00:00+00:00", + evidence_boundary_code: "authorized_visible_source_posts", event_count: 3, - distinct_observed_actor_count: 2, + distinct_actor_count: 2, + distinct_observed_actor_count: 1, truncated: false, events: [ { @@ -27,18 +30,30 @@ const projection: ProjectHistoryProjection = { source_stage_code: null, source_detail_state_code: null, project_matches: [], + responsibility_evidence: [ + { + actor_key: "text:prov_person\u001fkim oo\u001fdemo corp", + actor_name: "Kim OO", + actor_type_code: "prov_person", + affiliated_organization_name: "Demo Corp", + responsibility: "Source author", + truth_status_code: "observed", + provenance: "source_post.source_author", + }, + ], observed_responsibilities: [ { - actor_key: "person:sales", + actor_key: "text:prov_person\u001fkim oo\u001fdemo corp", actor_name: "Kim OO", actor_type_code: "prov_person", affiliated_organization_name: "Demo Corp", - responsibility: "Observed award owner", + responsibility: "Source author", truth_status_code: "observed", - provenance: "post_summary_role", + provenance: "source_post.source_author", }, ], responsibility_transition_code: null, + responsibility_transition_truth_status_code: null, related_prior_paths: [], }, { @@ -53,18 +68,20 @@ const projection: ProjectHistoryProjection = { source_stage_code: null, source_detail_state_code: null, project_matches: [], - observed_responsibilities: [ + responsibility_evidence: [ { actor_key: "person:pm", actor_name: "Park OO", actor_type_code: "prov_person", affiliated_organization_name: "Demo Corp", - responsibility: "Observed specification owner", - truth_status_code: "observed", + responsibility: "Coordinate the specification revision", + truth_status_code: "inferred", provenance: "post_summary_role", }, ], + observed_responsibilities: [], responsibility_transition_code: "handoff", + responsibility_transition_truth_status_code: "inferred", related_prior_paths: [], }, { @@ -79,8 +96,10 @@ const projection: ProjectHistoryProjection = { source_stage_code: null, source_detail_state_code: null, project_matches: [], + responsibility_evidence: [], observed_responsibilities: [], responsibility_transition_code: "assignment_gap", + responsibility_transition_truth_status_code: "inferred", related_prior_paths: [ { source_event_id: "award", @@ -101,7 +120,7 @@ const projection: ProjectHistoryProjection = { }; describe("ProjectHistoryTimeline", () => { - it("shows the focus event, evidence gap, and non-causal prior history", () => { + it("shows the focus event, evidence gap, authorization boundary, and non-causal prior history", () => { const onOpenPost = vi.fn(); render(); @@ -110,12 +129,13 @@ describe("ProjectHistoryTimeline", () => { expect(vocTab).toHaveAttribute("aria-current", "step"); expect(screen.getByText(/evidence gap/i, { selector: "dd" })).toBeInTheDocument(); expect(screen.getByText(/related history, not causality/i)).toBeInTheDocument(); + expect(screen.getByText(/permission, visibility, publication, and cutoff gates/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /open source record: VOC received/i })); expect(onOpenPost).toHaveBeenCalledWith("post-voc"); }); - it("uses roving keyboard selection and a labelled tabpanel", () => { + it("uses roving keyboard selection and exposes inferred responsibility truth", () => { render(); const vocTab = screen.getByRole("tab", { name: /VOC received/ }); @@ -126,5 +146,8 @@ describe("ProjectHistoryTimeline", () => { const panel = screen.getByRole("tabpanel"); expect(panel).toHaveAttribute("aria-labelledby", specTab.id); + expect(screen.getAllByText("Inferred").length).toBeGreaterThan(0); + expect(screen.getByText("post_summary_role")).toBeInTheDocument(); + expect(screen.queryByText("Observed award owner")).not.toBeInTheDocument(); }); }); From ba1de00e3842fd05322345e84ebbd559d57b2fd2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 03:12:48 -0700 Subject: [PATCH 107/118] docs(storybook): show project-history truth states --- .../ProjectHistoryTimeline.stories.tsx | 101 +++++++++++++----- 1 file changed, 77 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/ProjectHistoryTimeline.stories.tsx b/frontend/src/components/ProjectHistoryTimeline.stories.tsx index ca6b1cb38..49d4e8675 100644 --- a/frontend/src/components/ProjectHistoryTimeline.stories.tsx +++ b/frontend/src/components/ProjectHistoryTimeline.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; -import type { ProjectHistoryProjection } from "../projectHistory"; +import type { ProjectHistoryProjection, ProjectHistoryTruthStatus } from "../projectHistory"; import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline"; const event = ( @@ -10,34 +10,45 @@ const event = ( occurredAt: string, transition: "continuous" | "handoff" | "assignment_gap" | null, actorName?: string, -) => ({ - event_id: eventId, - source_post_id: `post-${eventId}`, - event_title: title, - event_type_code: type, - event_type_basis_code: "display_classification" as const, - occurred_at: occurredAt, - time_basis_code: "document_time" as const, - voc_type_code: eventId === "voc" ? "voc" : "vom", - source_stage_code: null, - source_detail_state_code: null, - project_matches: [], - observed_responsibilities: actorName + truthStatus: ProjectHistoryTruthStatus = "observed", +) => { + const responsibilityEvidence = actorName ? [ { actor_key: `actor:${actorName}`, actor_name: actorName, actor_type_code: "prov_person", affiliated_organization_name: "Demo Corp", - responsibility: `Own ${title.toLowerCase()}`, - truth_status_code: "observed" as const, - provenance: "post_summary_role" as const, + responsibility: + truthStatus === "observed" ? "Source author" : `Coordinate ${title.toLowerCase()}`, + truth_status_code: truthStatus, + provenance: + truthStatus === "observed" ? "source_post.source_author" : "post_summary_role", }, ] - : [], - responsibility_transition_code: transition, - related_prior_paths: [], -}); + : []; + return { + event_id: eventId, + source_post_id: `post-${eventId}`, + event_title: title, + event_type_code: type, + event_type_basis_code: "display_classification" as const, + occurred_at: occurredAt, + time_basis_code: "document_time" as const, + voc_type_code: eventId === "voc" ? "voc" : "vom", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + responsibility_evidence: responsibilityEvidence, + observed_responsibilities: responsibilityEvidence.filter( + (row) => row.truth_status_code === "observed", + ), + responsibility_transition_code: transition, + responsibility_transition_truth_status_code: + transition === null ? null : truthStatus, + related_prior_paths: [], + }; +}; const projection: ProjectHistoryProjection = { contract_version: 1, @@ -46,8 +57,11 @@ const projection: ProjectHistoryProjection = { project_name: "Northridge renewal", focus_event_id: "voc", time_basis_code: "document_time", + knowledge_cutoff: "2026-08-20T00:00:00Z", + evidence_boundary_code: "authorized_visible_source_posts", event_count: 5, - distinct_observed_actor_count: 3, + distinct_actor_count: 3, + distinct_observed_actor_count: 2, truncated: false, events: [ event("award", "Contract awarded", "contract_awarded", "2022-03-11T09:00:00Z", null, "Ada West"), @@ -59,9 +73,25 @@ const projection: ProjectHistoryProjection = { "continuous", "Ada West", ), - event("delivery", "Delivery confirmed", "delivered", "2024-02-20T09:00:00Z", "handoff", "Priya Nair"), + event( + "delivery", + "Delivery confirmed", + "delivered", + "2024-02-20T09:00:00Z", + "handoff", + "Priya Nair", + "inferred", + ), event("voc", "VOC received", "voc_received", "2026-07-30T09:00:00Z", "assignment_gap"), - event("rebid", "Rebid started", "rebid_started", "2026-08-10T09:00:00Z", "assignment_gap", "Bid team"), + event( + "rebid", + "Rebid started", + "rebid_started", + "2026-08-10T09:00:00Z", + "assignment_gap", + "Bid team", + "inferred", + ), ], }; @@ -95,3 +125,26 @@ export default meta; type Story = StoryObj; export const AwardToRebid: Story = {}; + +export const TruncatedAtSelectedVoc: Story = { + args: { + projection: { + ...projection, + truncated: true, + }, + }, +}; + +export const ResponsibilityEvidenceGap: Story = { + args: { + projection: { + ...projection, + focus_event_id: "voc", + events: projection.events.map((row) => + row.event_id === "voc" + ? { ...row, responsibility_evidence: [], observed_responsibilities: [] } + : row, + ), + }, + }, +}; From 6004a7ac6936db62a6dd261c29c16a899c7a9aa1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:18:27 +0900 Subject: [PATCH 108/118] fix(projects): keep timeline compatible with persisted evidence --- frontend/src/App.tsx | 6 +++--- frontend/src/components/ProjectHistoryTimeline.tsx | 3 ++- frontend/src/projectHistory.ts | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6c33640bf..b44580bcb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1800,7 +1800,7 @@ function PostDetailPopup({ .then((value) => { if (isCurrent()) setPost(value); }) - .catch(() => { + .catch((err) => { if (isCurrent()) setError(String(err)); }); const reloadContent = () => @@ -4837,7 +4837,7 @@ function ProjectHistoryPanel({ ); setError(false); }) - .catch((err) => { + .catch(() => { if (!active) return; setIndex({ contract_version: 1, @@ -4866,7 +4866,7 @@ function ProjectHistoryPanel({ .then((result) => { if (request === historyRequest.current) setProjection(result); }) - .catch((err) => { + .catch(() => { if (request === historyRequest.current) setError(true); }); }, [accessToken, index?.knowledge_cutoff, selectedProjectKey]); diff --git a/frontend/src/components/ProjectHistoryTimeline.tsx b/frontend/src/components/ProjectHistoryTimeline.tsx index 4a59a2fae..bd6e1cbe6 100644 --- a/frontend/src/components/ProjectHistoryTimeline.tsx +++ b/frontend/src/components/ProjectHistoryTimeline.tsx @@ -50,6 +50,7 @@ export function ProjectHistoryTimeline({ null; const selectedResponsibilities = selectedEvent?.responsibility_evidence ?? selectedEvent?.observed_responsibilities ?? []; + const actorCount = projection.distinct_actor_count ?? projection.distinct_observed_actor_count; const selectedIndex = selectedEvent ? projection.events.findIndex((event) => event.event_id === selectedEvent.event_id) : -1; @@ -101,7 +102,7 @@ export function ProjectHistoryTimeline({

{projectHistoryText(locale, "summaryCounts", { events: projection.event_count, - actors: projection.distinct_actor_count, + actors: actorCount, })}

diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts index 4121bbfb1..c7660e0b2 100644 --- a/frontend/src/projectHistory.ts +++ b/frontend/src/projectHistory.ts @@ -53,10 +53,10 @@ export interface ProjectHistoryEvent { source_stage_code: string | null; source_detail_state_code: string | null; project_matches: ProjectHistoryMatch[]; - responsibility_evidence: ProjectHistoryResponsibility[]; + responsibility_evidence?: ProjectHistoryResponsibility[]; observed_responsibilities: ProjectHistoryResponsibility[]; responsibility_transition_code: ResponsibilityTransitionCode | null; - responsibility_transition_truth_status_code: ProjectHistoryTruthStatus | null; + responsibility_transition_truth_status_code?: ProjectHistoryTruthStatus | null; related_prior_paths: ProjectHistoryPriorPath[]; } @@ -70,7 +70,7 @@ export interface ProjectHistoryProjection { knowledge_cutoff?: string; evidence_boundary_code?: "authorized_visible_source_posts" | string; event_count: number; - distinct_actor_count: number; + distinct_actor_count?: number; distinct_observed_actor_count: number; truncated: boolean; events: ProjectHistoryEvent[]; From 0a76b2516ec4aa28b778caacc727033c0065866c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:25:47 +0900 Subject: [PATCH 109/118] fix(projects): focus timeline on opened source post --- frontend/src/App.test.tsx | 6 +++++- frontend/src/App.tsx | 24 +++++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7ff66e56a..5bc7d4f58 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -7,6 +7,7 @@ import { setLocale } from "./i18n"; const signinRedirect = vi.fn(); const signoutRedirect = vi.fn(); let mockAuth: Record; +let projectHistoryRequestUrl: string | null = null; vi.mock("react-oidc-context", () => ({ useAuth: () => mockAuth, @@ -16,6 +17,7 @@ beforeEach(() => { setLocale("en"); signinRedirect.mockReset(); signoutRedirect.mockReset(); + projectHistoryRequestUrl = null; mockAuth = { isLoading: false, isAuthenticated: false, @@ -1131,7 +1133,7 @@ describe("App, authenticated", () => { visibility_label: "Public", project_evidence: [ { - project_key: "source-project", + project_key: "semantic-project", project_name: "Semantic project", evidence: "project was described in the body", confidence: 0.9, @@ -1774,6 +1776,7 @@ describe("App, authenticated", () => { ); } if (url.includes("/api/project-history?") && method === "GET") { + projectHistoryRequestUrl = url; return Promise.resolve( jsonResponse({ contract_version: 1, @@ -2007,6 +2010,7 @@ describe("App, authenticated", () => { expect(await screen.findByRole("heading", { name: "Project event timeline" })).toBeInTheDocument(); expect(screen.getByRole("combobox", { name: "Select project" })).toHaveValue("semantic-project"); expect(screen.getByRole("button", { name: "Open source record: Public post" })).toBeInTheDocument(); + expect(projectHistoryRequestUrl).toContain("focus_post_id=post-1"); }); it("clicking Weekly VOC keeps the 2026-W01 Voice of Customer post and names Event Lineage as the next action", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b44580bcb..51a442ecd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1697,7 +1697,7 @@ function PostDetailPopup({ knowledgeCutoff?: string | null; focusEventLineage?: boolean; focusAskOnLand?: boolean; - onOpenProjectHistory?: (projectKey: string) => void; + onOpenProjectHistory?: (projectKey: string, postId: string) => void; onClose: () => void; onSelectPost?: (postId: string) => void; onSearch?: (query: string) => void; @@ -2163,7 +2163,7 @@ function PostDetailPopup({ type="button" className="related-post-card" aria-label={tf("Open project history for: {name}", { name: project.project_name })} - onClick={() => onOpenProjectHistory(project.project_key)} + onClick={() => onOpenProjectHistory(project.project_key, postId)} > {project.project_name} {t("Open project history")} @@ -3669,7 +3669,7 @@ function PostList({ postOpenFromAskAgent?: boolean; postOpenFromProjectHistory?: boolean; onPostOpened?: () => void; - onOpenProjectHistory?: (projectKey: string) => void; + onOpenProjectHistory?: (projectKey: string, postId: string) => void; }) { const [posts, setPosts] = useState(null); const [graph, setGraph] = useState(null); @@ -4811,10 +4811,12 @@ function AskAgentPanel({ function ProjectHistoryPanel({ accessToken, initialProjectKey, + initialFocusPostId, onOpenPost, }: { accessToken: string; initialProjectKey?: string | null; + initialFocusPostId?: string | null; onOpenPost: (postId: string) => void; }) { const [index, setIndex] = useState(null); @@ -4862,14 +4864,16 @@ function ProjectHistoryPanel({ const request = ++historyRequest.current; setProjection(null); setError(false); - fetchProjectHistory(accessToken, selectedProjectKey, index.knowledge_cutoff) + const focusPostId = + initialProjectKey === selectedProjectKey ? initialFocusPostId ?? undefined : undefined; + fetchProjectHistory(accessToken, selectedProjectKey, index.knowledge_cutoff, focusPostId) .then((result) => { if (request === historyRequest.current) setProjection(result); }) .catch(() => { if (request === historyRequest.current) setError(true); }); - }, [accessToken, index?.knowledge_cutoff, selectedProjectKey]); + }, [accessToken, index?.knowledge_cutoff, initialFocusPostId, initialProjectKey, selectedProjectKey]); return (
@@ -4911,6 +4915,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean const auth = useAuth(); const [destination, setDestination] = useState("board"); const [projectHistoryKey, setProjectHistoryKey] = useState(null); + const [projectHistoryFocusPostId, setProjectHistoryFocusPostId] = useState(null); const [postToOpen, setPostToOpen] = useState(() => { if (typeof window === "undefined") return null; return new URLSearchParams(window.location.search).get("post"); @@ -4987,7 +4992,10 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean { - if (nextDestination === "project-history") setProjectHistoryKey(null); + if (nextDestination === "project-history") { + setProjectHistoryKey(null); + setProjectHistoryFocusPostId(null); + } setDestination(nextDestination); }} tools={} @@ -5008,8 +5016,9 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean setPostOpenFromAskAgent(false); setPostOpenFromProjectHistory(false); }} - onOpenProjectHistory={(projectKey) => { + onOpenProjectHistory={(projectKey, postId) => { setProjectHistoryKey(projectKey); + setProjectHistoryFocusPostId(postId); setDestination("project-history"); }} /> @@ -5055,6 +5064,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean { setPostToOpen(postId); setPostOpenFromCalendar(false); From 30dae74a97267e2fff7ede659531fcb56dee68bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:44:56 +0900 Subject: [PATCH 110/118] fix(project-history): preserve exact identity and isolate matches --- backend/app/main.py | 10 ++-- lineageweave/project_history.py | 37 ++------------ tests/test_project_history.py | 34 +++++++++++++ tests/test_project_history_api_contract.py | 59 ++++++++++++++++++++++ 4 files changed, 104 insertions(+), 36 deletions(-) create mode 100644 tests/test_project_history_api_contract.py diff --git a/backend/app/main.py b/backend/app/main.py index 155f0447f..58d896768 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -188,6 +188,10 @@ ) from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL from backend.app.project_history import ( + PROJECT_HISTORY_DEFAULT_LIMIT, + PROJECT_HISTORY_MAXIMUM_LIMIT, + PROJECT_INDEX_DEFAULT_LIMIT, + PROJECT_INDEX_MAXIMUM_LIMIT, ProjectHistoryNotFound, fetch_project_history_index, fetch_project_history_projection, @@ -3233,7 +3237,7 @@ async def read_calendar( @app.get("/api/project-history/projects") async def read_project_history_projects( - limit: int = Query(64, ge=1, le=128), + limit: int = Query(PROJECT_INDEX_DEFAULT_LIMIT, ge=1, le=PROJECT_INDEX_MAXIMUM_LIMIT), account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: @@ -3255,7 +3259,7 @@ async def read_project_history( project_key: str = Query(..., min_length=1), focus_post_id: str | None = Query(None), knowledge_cutoff: str | None = Query(None), - limit: int = Query(64, ge=1, le=128), + limit: int = Query(PROJECT_HISTORY_DEFAULT_LIMIT, ge=1, le=PROJECT_HISTORY_MAXIMUM_LIMIT), account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: @@ -3291,7 +3295,7 @@ async def read_project_history( try: return await fetch_project_history_projection( conn, - project_key=normalized_project_key, + project_key=project_key, focus_post_id=focus_post_id, knowledge_cutoff=cutoff, corporate_entity_ids=list(account.corporate_entity_ids), diff --git a/lineageweave/project_history.py b/lineageweave/project_history.py index e9684150b..7f2535d96 100644 --- a/lineageweave/project_history.py +++ b/lineageweave/project_history.py @@ -64,14 +64,6 @@ _VOC_CODES = frozenset({"voc", "vocc", "voco", "vom", "vop"}) _TRUTH_ORDER = {"observed": 0, "inferred": 1} _DISPLAY_NAME_ORDER = {"source_project_name": 0, "semantic_project_name": 1} -_DIRECT_IDENTITY_FAMILY = { - "source_project_code": "source", - "semantic_project_key": "semantic", -} -_NAME_IDENTITY_FAMILY = { - "source_project_name": "source", - "semantic_project_name": "semantic", -} def normalize_project_key(value: str) -> str: @@ -187,43 +179,24 @@ def _normalized_matches(value: object, normalized_key: str) -> bool: return False -def _direct_identity_families( - match_rows: Sequence[Mapping[str, Any]], normalized_key: str -) -> set[tuple[str, str]]: - """Identify events whose code/key directly selected the requested project.""" - - direct: set[tuple[str, str]] = set() - for row in match_rows: - kind = str(row.get("match_kind_code") or "") - family = _DIRECT_IDENTITY_FAMILY.get(kind) - if family and _normalized_matches(row.get("matched_value"), normalized_key): - direct.add((str(row["post_id"]), family)) - return direct - - def _match_belongs_to_project( row: Mapping[str, Any], *, normalized_key: str, - direct_families: set[tuple[str, str]], ) -> bool: """Validate one evidence row against its authoritative identity key. New callers provide ``identity_key`` so a human display name may differ - from the code/key that selected the project. The family fallback keeps the - projection compatible with earlier rows that supplied a matching code/key - and its paired display name separately. + from the code/key that selected the project. Rows without that authoritative + identity can only match on their own value; a sibling row must never make a + second project appear in this history. """ identity_key = row.get("identity_key") if identity_key is not None and str(identity_key).strip(): return _normalized_matches(identity_key, normalized_key) matched_value = row.get("matched_value") - if _normalized_matches(matched_value, normalized_key): - return True - kind = str(row.get("match_kind_code") or "") - family = _NAME_IDENTITY_FAMILY.get(kind) - return bool(family and (str(row["post_id"]), family) in direct_families) + return _normalized_matches(matched_value, normalized_key) def _prior_paths( @@ -351,7 +324,6 @@ def build_project_history_projection( matches_by_event: dict[str, list[dict[str, Any]]] = {event_id: [] for event_id in ordered_ids} display_names: list[tuple[int, int, str, str]] = [] seen_matches: set[tuple[str, str, str]] = set() - direct_families = _direct_identity_families(match_rows, normalized_key) for row in match_rows: event_id = str(row["post_id"]) if event_id not in matches_by_event: @@ -359,7 +331,6 @@ def build_project_history_projection( if not _match_belongs_to_project( row, normalized_key=normalized_key, - direct_families=direct_families, ): continue matched_value = str(row["matched_value"]) diff --git a/tests/test_project_history.py b/tests/test_project_history.py index f073a0388..9c8a38ecd 100644 --- a/tests/test_project_history.py +++ b/tests/test_project_history.py @@ -137,6 +137,7 @@ def test_matching_observed_project_code_keeps_its_distinct_display_name() -> Non { "post_id": event_id, "match_kind_code": "source_project_name", + "identity_key": "P-100", "matched_value": "Transformer renewal", "confidence": None, "ontology_iri": None, @@ -154,6 +155,39 @@ def test_matching_observed_project_code_keeps_its_distinct_display_name() -> Non ] +def test_project_name_cannot_inherit_a_sibling_project_identity() -> None: + """A display-name row without its own key cannot leak another project.""" + + event_id = "00000000-0000-0000-0000-000000000001" + projection = build_project_history_projection( + project_key="P-100", + focus_event_id=event_id, + event_rows=[_event_row(event_id)], + match_rows=[ + { + "post_id": event_id, + "match_kind_code": "source_project_code", + "matched_value": "P-100", + "confidence": None, + "ontology_iri": None, + "provenance": "source_post.source_project_code", + }, + { + "post_id": event_id, + "match_kind_code": "source_project_name", + "matched_value": "Unrelated project", + "confidence": None, + "ontology_iri": None, + "provenance": "source_post.source_project_name", + }, + ], + role_rows=[], + edge_rows=[], + ) + + assert [row["matched_value"] for row in projection["events"][0]["project_matches"]] == ["P-100"] + + def test_summary_responsibilities_remain_inferred_evidence() -> None: """LLM-derived summary roles must not become observed or an HR assignment ledger.""" diff --git a/tests/test_project_history_api_contract.py b/tests/test_project_history_api_contract.py new file mode 100644 index 000000000..252c8e439 --- /dev/null +++ b/tests/test_project_history_api_contract.py @@ -0,0 +1,59 @@ +"""Unit contracts for the Buyer project-history HTTP boundary.""" + +from __future__ import annotations + +import asyncio + +from backend.app import main +from backend.app.auth import CurrentAccount + + +class _Acquire: + """Minimal async context manager for a route-level database seam.""" + + async def __aenter__(self) -> object: + return object() + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + return None + + +class _Pool: + """Pool-shaped test double that does not create a database connection.""" + + def acquire(self) -> _Acquire: + return _Acquire() + + +def test_history_route_preserves_display_identity_case(monkeypatch) -> None: + """Validation normalizes for matching but the buyer response keeps its key.""" + + captured: dict[str, object] = {} + + async def fake_projection(connection, **kwargs): + captured.update(kwargs) + return {"project_key": kwargs["project_key"]} + + monkeypatch.setattr(main, "fetch_project_history_projection", fake_projection) + account = CurrentAccount( + user_account_id="account-1", + external_subject_id="subject-1", + display_name="Synthetic analyst", + preferred_locale="en", + corporate_entity_ids=frozenset(), + permission_codes=frozenset({"post_read"}), + ) + + result = asyncio.run( + main.read_project_history( + project_key="P-100", + focus_post_id=None, + knowledge_cutoff=None, + limit=64, + account=account, + pool=_Pool(), + ) + ) + + assert captured["project_key"] == "P-100" + assert result["project_key"] == "P-100" From 5d0e412aa540e5d5d3e3413c57f9c19d22927c55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:50:53 +0900 Subject: [PATCH 111/118] docs: refresh project gap exact-head checkpoint --- docs/product-technical-gap-baseline.md | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 2b1f289db..dd3cf776c 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -32,6 +32,36 @@ timeline entry point, Storybook-compatible truth rendering, and live PostgreSQL/API regressions. The final exact head and Checks must be recorded after the ordinary push. +## Exact-head refresh (2026-08-20 19:50 Asia/Seoul) + +This refresh supersedes the 19:14 checkpoint for the PRs it names. It records +GitHub observations, not protected-main behavior. The repository has 25 open +PRs; no approval or queued Check is treated as merge evidence. + +| PR | Exact observed head | Current observation | +|---|---|---| +| #258 | `f8d2fa98` | `BLOCKED`, review required | +| #260-#266 | `dfd95d9c`, `bd1b4d2f`, `80445b8a`, `d670acd5`, `d5dbdf71`, `26a6d9c6` | stacked, review required; #264 is `DIRTY` | +| #282 | `6eeaf89d` | `CLEAN`, no formal approval | +| #285 | `30dae74a` | `UNSTABLE`, exact-head Checks queued, no formal approval | +| #287 | `26fa7346` | `UNKNOWN`, review required, exact-head Checks queued | +| #298-#303 | `49c9976f`, `59ccdf91`, `40b0a8ea`, `b7e6e82d` | mixed `DIRTY`/`CLEAN`/`UNSTABLE`, review pending | +| #306-#311 | `e0dbc386`, `a4d1de59`, `42e6230c`, `e6fd907e`, `d8b7f561` | `CLEAN`/`UNSTABLE`, review pending | + +The #285 exact head includes the independent review repairs for case-preserving +project identity, route-specific bounds, and sibling-project match isolation; +the local tree recorded `741 passed, 16 skipped`. The #287 exact head removes +the Semgrep dynamic-SQL findings and aligns public claim adjudication with the +contextual-orchestrator `mode=auto` strict structured contract; its local tree +recorded `791 passed, 16 skipped`. Both remain open until current-head Checks +and protected approval are observed. + +The organization-owned `.github` repository already provides the hourly +commercial-readiness coordinator at cron `7 * * * *` and the review/merge +scheduler's hourly fallback. This repository does not add a competing local +timer; the central OpenCode/scheduler credential boundary remains authoritative +and `COPILOT_GITHUB_TOKEN` is not used. + ## PRD ### Problem and outcome From c92c5d14bf01d71c73bbd1d39720faac9b182765 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:31:53 +0900 Subject: [PATCH 112/118] chore: remove completed project history bootstrap --- .github/repair/project_history_payload.b64.00 | 1 - .github/repair/project_history_payload.b64.01 | 1 - .github/repair/project_history_payload.b64.02 | 1 - .github/repair/project_history_payload.b64.03 | 1 - .../apply-project-lifecycle-history-pr.yml | 223 ------------------ .../apply-project-lifecycle-history.yml | 223 ------------------ .../export-project-history-source.yml | 60 ----- 7 files changed, 510 deletions(-) delete mode 100644 .github/repair/project_history_payload.b64.00 delete mode 100644 .github/repair/project_history_payload.b64.01 delete mode 100644 .github/repair/project_history_payload.b64.02 delete mode 100644 .github/repair/project_history_payload.b64.03 delete mode 100644 .github/workflows/apply-project-lifecycle-history-pr.yml delete mode 100644 .github/workflows/apply-project-lifecycle-history.yml delete mode 100644 .github/workflows/export-project-history-source.yml diff --git a/.github/repair/project_history_payload.b64.00 b/.github/repair/project_history_payload.b64.00 deleted file mode 100644 index 872d0f192..000000000 --- a/.github/repair/project_history_payload.b64.00 +++ /dev/null @@ -1 +0,0 @@ -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 \ No newline at end of file diff --git a/.github/repair/project_history_payload.b64.01 b/.github/repair/project_history_payload.b64.01 deleted file mode 100644 index c0d4e5f6f..000000000 --- a/.github/repair/project_history_payload.b64.01 +++ /dev/null @@ -1 +0,0 @@ -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 \ No newline at end of file diff --git a/.github/repair/project_history_payload.b64.02 b/.github/repair/project_history_payload.b64.02 deleted file mode 100644 index 496ba6419..000000000 --- a/.github/repair/project_history_payload.b64.02 +++ /dev/null @@ -1 +0,0 @@ -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 \ No newline at end of file diff --git a/.github/repair/project_history_payload.b64.03 b/.github/repair/project_history_payload.b64.03 deleted file mode 100644 index 4068a4b77..000000000 --- a/.github/repair/project_history_payload.b64.03 +++ /dev/null @@ -1 +0,0 @@ -tIt2aWmHs3DWHfVWSnY0NVbLRmtJfrMURZZjbBeuK/M0RXYHz+3Kr67qhhLBnzKGQHSJGHYDeF9//TWD+KJpVPgh4/ZnzKkXlqzIDpjO44Tl7Z9LSlm5dO8F1pV6SBhXw2gedGsVc7eslroY2JEcw+qDxOp9CtuGbxLs03bu08wZcEMa27DQtuvFbL/Yxc4Bj7h3Zs6NHzqY+lVcVkU2asQNJ6dsbSXVrnW0u3O4ezwQibmcPxTQ7KnLg4xUEsW8KgSyCsIS022qk9IFi8xjEi4/bgKIJ6fKMNL6cdpgltRn0YRb8uj1QCWwdcZYvxfAfO/4MdWgu6gCwFSQE900zMLKtwtvFub9BuorLVIZxklsh4F/o8QPd5+ALM4jITaevsSY+oaRGaHnscZVQP3yvcG4UBH1EeVynuXX/Nh0gY9UxVRJptSkCcqxIntwIDSgsiyqE58Hg3Uz8FK5mZYX3A80Ea30X0DYNQW0DY3gO+fUxyGwGSiGz72r5mfI90LHzgQMMF88wpqQ6sNN21DUXjZYl7QMxHHF2+OdngHUwAvME4c14ZUruTAegJcDA0tfhJwvEqw+l/XK4O/6n4CkQW0+YD7bn8KmVMTftLg5NAY2ZeF9WDpiYOkeEjPP/gBixsii1woa3rL5KUTNqcIzeXONJzSmHigU/5Q21QMl5p8StcXi9NLY3NtixRxuBZnIwBNd8DXbWu7ELEOQ1coa3Hrg4ny4a0MeTPiksd8HZ3M5TI1gVtO+AjAbDV8Qb0LYABi2GUkGVcGF1xLQd6pxtDASSGPrGCy83P2690jispBuvAre59Kwj4poyp/+g7mjNVVkmZ3A3hiiBQecop56jKIQvt7erq9Wq64LSrXGTaOWqYIjEyMUEmI369P1nsSKzfI4/3SUPyGh+IfznO+Xi5eeC+xjVCwyB/e1PoVbkom8k0d7ElEf/n3ycOMTl4emtucSsZgHX+Ro+LEEAh7hqFtjVSqn5IWqouUIRl82WaNkzutxua4gP7HGYTQDIKc6UBiLbq2rgniMrAHtJIOTwmHyKdahPI7mVB0qXuvNIItD/VPd/KgS0PyUn8P9Sh2uXLJ7DQIno9OT2ojAKQsCDF88JW7rS3GTqjZ6L+9UaTF25qDa+RAsMcHYoi/NyTiVPCrXY0XVZVmOPqNbptUZ5aQGESv5wJ2QtHJhfinkiE6dIPHGA/jpxAMHmj3Nm0IJqzcr4i+FEemkNdalM7Bl5Zm4FDBzkkvfO08vABzA20cZN3InhWF/iJzZZZ+8AQM8njlj2ieHr75nf440oA+DFF46zv6/Xrc9O/twf/8YzBekZNe2kT1su4e5xqF/Rbs9vE2M3AsyRhb0weH+93uvdwE4H2RNkrcsNxzHayk7r1WuIKXMPEwSX5r3Dn5s3I8n23zp4nStyySZxZtrayBpE/ohmTv+tRe74RR8kuGFl1zOz4deuFaslV9F/q9WTxbl472fkJQVNACL6+vr4fWzYRhdrG2MRl+uofOiAB9W6+eH4AcxTuRKAXrbv5fN3Sonb2WyU/AUr/Uei6QtvE+fUFZ2JBx7otk8bFubq0AQ85I5XKpJXKJgCwofWCkmhPgGjWlXbLY+SvWpk2xZyTxCH6ynMoTQgN2DH4c1ZWm5oBvG8/MdvDC0P+kTeVYeHlOUcY6/C5opuenhqQSbmSa+5bup25n6lcVxYYoMaaDGjEbpjdfVRxo39XA751tjGF86seCMXeH451zBiZTcyDMGQ3ceU9cYnpPQ98FFzXDVIGpxWowZnAmwgTFMhSgSZ73HIaPnkPXHlkQYrIbh0c2UlQAZp1i3i6fuuv84RFlK4x67qFLA9AFbN+1olNu6h9TxBwf846N/vsY7hknkgIWb27x4CVqux1WKZ24lR9RxyTR0qf9opq/BG9DyGOjcgc6eDuOnsvkZnHnko5XN9GUKDD6LZz7eHYZX84C9VpzdfN68RaOJZZhdKD49i2/G4exiQ20xxMMpAeNffVU0FC6SK26dBlfJJaHq3x1fKVfN3n71094bbFmH9brjIQ2uvCgMhhc06aroC+v13pvd7R92/7W7/fOufbx7VOiLlw1lyaoJKxX3v/rgl/jh2PEv4ZO19GNpObySbkym1GznyvHAp/RFUeDzMPSVPIYkulF6jh1wiP0+FA0xu/n6yS6fgMBaO4TzZGujJ9tas4qcuB0tH8cW92E+jOksl4fDfTBfmDoFrwSv0G5q4qUQFm2fxbjYxatK2DqsUDYufu/NvInSdkejrZZt+yrQwLaKw2BrYgUhvh5fIixSsOkwzYZGxJGV2rc570qnBq6qsHCdxGHNe9w46GZvMJLIyiMwwQH/VWJlZinh2WZqJRU2f09tZdm+kHswN8y6HKGhHdGZjyEqtAyBT9ZuSxO/s3ort1ZpgcaJ9wFteJWlrvo+6Zy7WmsM3ssObLKEEodMYPde+jdk6l1ELOE4HUJUGpif+95YVurHIFup22fNhnnyS9aP+jFcJ/xT4g7pwO/EKkZyF1zQW7T7h/jnOZhgl/TDyeb6xumdpeRmu1PwfbfuU7o9ZbhDZ56EvIk2DKGqMxkncHjjeRSHEbCfExP+Wllh8seH9AOWA6JdMPCGIOi71pizZsaHkvIeJMGQx0MZyD3gusSbeDQqS8per/fYZlVRbOMByXIxrmwc5R3M6ziqOKDyEDoUyJip2GndHEct4ayu4lEUF4kYaVkbjUbrrAyU5/h2PL6kU2cI3GWhJ+a4Np4IdXu9VcT72bP03n6cwLZCUXZFAwcl8ccxgRejBfGbd59vfQq64IS6NQHGC1g81guSkKBIDwPbD8P385nNmuqaGAL/dQVQzBm6AGr3SfpB6EqHpcWjLHGzZwJDNlXZkKK1dK4dzCML0fCxKTuTslkdzU6fWFIDAJzpzAlu4MHOjniJjpBlCE2WYMmuV7DTIhyGW2bymB7w59rGLvKugKoK6IkHTeJ3FY7Z7TpEDF7jf1jjUBJOJNFLG422iN5UA72fwKenidm15XnnMVjviBiokvS15MLuzyPCnuxJIlcrB8DAwZw34OQkl1ggoHrEDSVlVBAml15wsSKS/g+jwMpy15j2WhDoKmpLPIqmeJ8UFQPnsVVUZKJE12Bn//BgsI1bNb3cIinhgH6EPT9uQY+VsPxOE8vvSliakCo8VoUFlhfYyHPlQ981nPipSBk2JY9VnL3FeW2SjE7svF1pvyGNERgmAwgxxg7dHN/v9u7+GIJxDi9tZzwO50FiTCiCa0SjAF3X+TlvCiDNza4Xz3wnk4lTLNDquG5EY9kiw/XygctDNDgqKZ8CZRVLkksWkcZG3gJlO30TYiL7Ug6hysz+QT840xkms9I4MS2/imxky+corKrYyWcEgqcoIsKAdnsno9PHCC0X/7EgPbg/RsQJu0En0x/jfnAFtWMOaO7mmYPJI75YyMgczM+NQHEjZ8KRIr+z7F3Mlpa/l1mzE3TObx5BsxnUblUNVygjago8U3K8UmlB6GkbbAgiv9raN4kth3seujd9kvr/Sq5Kvh/F4/lmMouwWDa2I8y5QUXV/x9xn1T/LzdC/qQx5HKVKm5RG9xdxhaoa3KlCxvIJNjUYzgpqKVToyOY36UTK810Z07NLRvhzjI6hoXVSbC6FJYTsdD1YaPIelBYYgpATc0il0sXo2BbklFMNhmD2DMCyYggSjuCJFG3zujtPbbVK0Qh8/6NuBbYrTayNs2Z0gzqPkIlzrXDe+HKHkOWQ2rwvMXPXPAV9qcfjJ4N1p8fj77eHI3gf/82Yi2Z4TorntGxcXoeAVBv4o15jT6scn5hiK780EcQ9tlg9OVgtL6ahHWp77Fi76Koj2kavxLwSVo0SIu63y1S9/lgfX2w/tWKUpdZl22RFoGboeuiNHgBHFtmWukIG6K3Zf2ySuuBtonpZSgXlze8DCA4Ngbro5Xj7j9I9FoyLX3xJjaL2HYLdd/6pFi0zWyo+QA45fkGniLt70tGl48jJ4gxzRBzAUyEblfiUIdXOwctj5adER7rCtOuXy0ln34suf2rJl29IWZG+nW5FSV/Y6k0UYRhc1sJ8TVkQ9WaSaamXbVxNCmQgsPpa5s3tRaMsYkXzQ+Ts9ayPGqtC1NTZrVRa5vKcE0tN0ZBr9drY320DUk58F43Sd4BVHQ3vdXW0xgnYmorb3TRF5EpSVqGY9D5EYuSYFSoIJz/gIcGeoYFo53Rw4aShSIZqit3Wck4xfwZQ7l7iTye5fKhPVPJSov2VzHib0lG/FuI9BtgtlU9KDAplfpZHPAEtsHpCsVLMxHPEJO+/bUycdasvmNbNrmegZuhJyruZBAN29/mDNI6jKvA27OihbGli3ux8wyHaZreq4tnz4R9xo+yQQCyjkWFusgqlZdSNclarJT2659mWkPNmTaX48QzaQLJQeo+0F3LrHUmWbrw/u5aZlFb7Jf12MajZLJI75PPyshNGs6kZlMncuicz8xCL8tXk5AzAzTlt9OP/fhf+2iehjNMIm0ztMFKKoC6M2SNim4hxDq6CZJLmnhjwhp8yJZAZB2iTJlCs2kZowOl4oE/OYFzYRCr3JT8kZfVEL2hnpRUWQes0go+KWK9P6DtVe1BZDbelbcfkjUFCjdIlfJGH4wjsTzR7GJqz9QNj0LmZ9rTaSWtDLYmKxR84RroI4+8FNrdGI69ZNqurmOWwtEQO/3Eo6FRnngijgdfDDZGpc8lofMwkVGNqhpTQBqlb6ZCqWodmlao9WV6wKZOLaORr1zR1zebVDpDXN8YrL+om/qG5tTbCqEpT3cJ0UpnkoXpsis9smOsUEBLKChmnOOU+yRvbyd9oUT0qumTYmSrKg//jG01jG0t7Q20KseRNa0rTQeT6ltMqjEmZ+mMSQ2fl34UJ56rG/ASpl8ujk5TeWRgqWuCPStg9RYr2bE6i+qVjSde4Pi+XnW9Ijp6dZZvPOq70mUzb4s1Bu9+v81ufb2rVCh511Gsl6lLpXaKWdaos6ygpRuFsxUtZ8lcHU4LNW5p+/es6wG7ZW/zfiQ2v+br/YYKi2s9bHNaZLtNeaezX1fwJS+mrNy3qVSP07l2vKyPg7minFqV3vNWtgw5E91olwkkLVltcc2pp6tOahZZ/cCg9xTSiy+TvpTXaAiUtb0sdVNnLVGt/X3ZtmB5Hr2lgdMJO8ASLWnzU0vACt0j9il6MZImfLXRLZvjierS1SfAG4JWyjI3BJNlgajAOtVYydv0sLDQ3ba6nrI5yRwMMkBdZ9s7XNVbbZotphaZA1nMpFGBeqexIFh8K1+ThQ7ALSyITvvCkyi8BsmUn1YIloGPNQTAfQ1+NYVC9cyTn4D1DcBLTyzTM0gTMNNTPQ0sdYTDyYUzO7Hgj+06N7FYWnirsbQgMt3wikY2gMlWc31jOOqT56Ph6FRz56jSPJsomIZezLWrbHwSazf4tMCtduo4K8fHCvReRkEV0D0T5kgWvTkP54HrRDdKVCu4F4VCRbEdBv6N9Yl2wJ14fgISU0TvwTZ2Z6EHwo31wgUrBW/0FIWeElcva2WiVvJLzy0r+ly1rp/0weWy2Q15Exvrd6tP1pXYXMFf9sJhNA+6zT1mA5WyWPzhgc6donFC3qMeG1mOMbpORcF/4ofj94sdO5U61Xcm1MG+O9IdZ+S2oZl+lWmDRMk2QZKottVNsNPmryWFXOce1rO92E4uvci1Awyp+TZ6nkzKse5SN6LvnsxoSmKIbeysywXmFih0ytDqkyGPbgRuDfbUNIFtCusR0HZBoNAIOV0ZcQ4i6zg5wD4wa68yuLoYis5bZtETQIfxpTZ6zntaJZ/1k/hQB7g8Kkwt4B611TJQFz3sSuUJSY9mOTwWQNGGVr4XoQ1uqRerArm3qYaNsNonaZsvtp5Y/g77SdIPHjbsloF7mzPEHauel4kZLfx8GnRzyKkVZ1u9Hvl2i2y0zekijslos5VPKcWjjnYyQwg6L+FaMFTBABBjKfKcwNALXPoBISowmGw159QpTJ3dZdTTm5EuvzbYjGrUylZsgfbKlMSNUI7ys1lnxQgYLQxDri6gOviITmiEAYFFRp/JJ/N3ijk2PcsYEVrH0hxBWSaG6CFXo59s1vXTkozFdTTkdYpYNW+HYGdOxlDB3PdJgcQyIxTCP7qKJUW0gbkrS77cvtVE6/PCJDVgxePImyXxWkzzcEM6VfD85dlDWJrqGLH0i0xsa4jsqrHIbvwKT+YTc5edmcfc4vP5DY3seB5NHNgKTkTtay+i7uP5yI5Xco8tpDUN3DVnNlvDL5GlNB0cmGtpiEkUBgmOEUdjGMcbJrH+ELPlQ2zPZjDEh8d1pdLNwYJXgqhDQHNY4YQ0oCUXd6tJIuHSBVZMGVcpgnT+gZO5oGAO4RquCWzitduCgrxbS5HrvevICyaNuVj1iTb6RLLoB7ZeGFNlmzY9BvyxsAxAEO0BJvOA5zWxmRgYxCouteC62wr2B05AfXJHGNdaMkGS4Ro2CgMhFAAPlIEee1OK3b6tl5wJgHEehUcjUPjUicaX3cj6pmaeJ+/exe/eHZ1+IbgkLUW/JXcdogPzQXFy99ln2wd79vEvB7tHeLzxAKPsspyZ23cBIctqDrCTGy+4eIk/qmTeLPuOdYKt+zJrfZN+Xig0VP49uDXFD8nvzMoTsMqGYGWg4pc1AwZJ6IcXN7YXefUPsr4ImzDc9JxG8NXdO9ioy+jICHiYpjowQj5QXqI06AP1Hkq/XUyUuefruhXQoFoGHNtx8p+mFCquDM8b2SQTzBl5iHSHJd94O3Ngyuy41IOuTqHmhkPp+0K2SGVq9RcXHvzRIonzhPaaj5PQMC/fS9wfxXH9D84spSdAC+fxPRQEYMk9X2OeAqsyt/ApbNaFzzB1o7CJ+IcszWGT3Ju08ODMhMlS4hJQ8+XFLgQdFuUQoFAjBk9OS6ye/6i0x7NfLcsV2nyIvwWEUkLFZs26id8lEehgzAvbJOch8J0TlHkjzYMAst6b1ZDStcMUxPe7xzs/FhTEfYq+i+M5Y/Cs4+MQTMiUoP0Cpf8nXwD2se9NvQTgb4xG8L7H5jf1YlpRgd/yZQSpEmOOR+RM8Q5uQK/J28PXR0xvHrBPu7cc5CY5YoN02bseueuJ5WCtM4SJ+z3OoToSmwUhZ2VD8W+3QETY7W8P93ZS06Gbz6mXGY9//9stx2+YhAKF3t1ZnwMtEId90suJLe5DgFPw9ngXCS5rsgZ5cjzzHBbTtot5F+lGEIgJmsEmgqH/OafwewDkzP1kC1eGXNCt9T7x6daL0aiXT4ZrwB3UzEGyzd8DgFd0BtSNu6iyxvzLtBFX+vAMWHQzy34/gHeV5/AH+GOW2pGXL9kObk43OQjLsg75cmInNsbe1K1JEnAmIBnI4XfbOwQ2TX6PcRs+4dkAHB6okF/n4M9yKYo07KZIiwkzArMbI4jd0Bmz34sbI2EQCMTY+SjH7P48+qJhhs/3yx8Vr/GVvxF4DWsyL+LKT9nKbrG/hW96OGm+xw/sg+03u6+R5YrP1dmgZcgldt66Lby5q/6warPeIomHeeBQrMjf/05OThceFhx0AI/sufzRPXfhV2GwD8yDP9q6DYMj6gNkfFf54dq3fN47P26/+WH39f4P9uHu693tI7bl/vpXcrIx3MBEPzIg/HbwV4ONET7yV/hy2wUOwzcDcnxJefAo7QU2cGnieD75DmMkRMRISBBeEwqqzY2B9zLWGzBmTanCxWDKrYnwP4ZkO5PShGWH90lcrBY+4PndfZJet+6Tn/d32Bojm0f03HOFGiPxpRNRtk0Sir6U45MIkWW8DJLd98Zekmm0DM/+ogbjehYslbjPhhEaepCtYaquUI/HQyIK04QRgmIFtfMNGNEp/DKGCeC2R4uOo5uX4MNBco3JRwS4DBZwffwSvw7HHvz4igIRg/dAZ5hpQBEDMGVieA7oh/uTmZx448KFJYtJd/vVIRmtgzgbip1wuPPj3jGI4LeHu/YR/Hdv/w0yBV98slu7doWFE9saf362JEZ41idntSY86oez+jj6GZ/12YN27hkeiE8xUepdwPNZGOsc8ESAo3++JkLtwyKyW+JiBmk0lrE0qHwg/jyGVcGMK8bM7wLkbwJcdpEyAWKUyZ5CWzCSGhnknMIYlABXsYYc8SXFHfAuYJPLF7i4vn3gEiLyC0tsxFFjSIhNNgtnc+CCCNkm4qgCXUBb4W5MNQFKcDIFne3DD6JwfnFJzmqE2tlLTrP9f70eoPO/Bpr458E+0oclOngBOXPDcbyWeoRr1Z7E4pfDJPHPMBMe58m2SQi+hCMok+fa9dlZBIiD1Azjm45bYkgDwaqAPzgiSP8Sr3Y+OzreP/zlu/39/6lh04N7+ZKtZxrwSL/JRM4ZrOlVSk8MkPg0oTkkxHtQWK53wIXBBez94hLypNFMHAz4Hnf7uXnK+RnEUHKT/QxAJbif+UojVthMHNiQxFmyOWYoskfZto5gvyOaMaZ+cNINgNwBtysDFI8pFVNOS3fOFeUopCw2wBRmUL9J5IB9hbJCJBu68zE/4MmCRbnUeEm+9y6mDjk7+m52ESb/NT+++unDrvPh7fVVOB4dnSGxXErOno2+2tw44+v2Gdpm17ABKcu1i7vsb7U4W57L+pmwcv6FjxAnY+yYJiSckLfH3w++glnBxvDYOjJwaNF8lqbH8MnjhMcsxMzOVPnPACRYy73NLHSEOYtpMmD6XK/0rcgHHE7fu17UFcmBW8fRHKuWoy60w/fsbeUxPmUWyRZowO/RmobF2rLmyWTwldXj1GGJd+CPhMAUXXw498xAPPiueAWGf+U65meZRcgAMF1HP8CCwlKPYZexFZ+g7GBX5Fw8/MFlPo/we9BMHliJbka8IjEyNPi0cB7wFZtZHqJfnA/+FhMDKLtqgj/Kac2tw/Qn+N2QGXRdmGKP/GWLrBd+64A7RI5uYliw3Q9e0p1YYkoMaQR+m6F4t0luAcbJ5lej079EdwKN6iqwAQWpcUhGUEwNWLou4IuArK1flqkTvaeReOO4roebZsnybDM4bHVAps6nXD3H4lIsmiPhHKT8HC0Sh0lzL2lhUTjK965LPc3Qc52Bwf9FNlF4CR6EdR/l/BsbNDANusu2di5r+OYU+pXd6kQuBa0eFqxN/uvUxsx3fFG0/HC4u/vG/n7v9e4Rn3VpZ2UTrj1B6+dfLx4IoW9UNAVEbP5o/+3hzq59sH90bO++3vth77u913vHv9hgdyB1jEO8D2D9mVWtE5bhpkOkzFN/x1318RxgT0GxTB3YtNE7dM4Lvy97+l9IPX8vmnVHlQU0FwNlQPlt72g+nTo8Tlb4dX448IXEkwbQqwSYYKAdrjO6dZElwr3B9H0xfpQ/KQJs9NuaOfL41hePPXxzQqXnwQVK5cdhaAbvoD77bp4kgLQ4C3tXOeCq/OwdxvjMA6yDt/y87l2TQ7giplokqx6DfQMWnw926xvQIFvvLOZPDBjkS9CHuOe+vcX9+CYEMcu5HA37KzAgWOoIv5ICH0UU/CCX3NBkCPv07pu12bclUpSH7d0t/fKbNaEFWYBiyY9u/zIBrRmzQPNroI9zQcl//ifmPX8ak/2C5PGoLxRJcC+fPJS8X6Dizv7BL6R0p+L5iIcGBZHs1GfATC2yxkEPAK3ohl2YY8Dd86G79nxjwEJE4sFB8cHy0j3eoPeN+VBa2v3jPhtUHeTSwxoLVbjGUKDZ70CkF/bnPfLyZc3Hv/N0tsK3946bXWUoQHqXzG7AMA2ekVI6mwuuLbvZB+ZAeRUbPbDkR4tJcvcRzJrdiAeGYDL4rLMGePD8GtE7a2O4/l/D0Ttr8XMMcsLndSAzWToDswe21/B/45DVnnyXwnhnbVaAL3y1HH6B8dIY7HDqFsnHw7AIvBiGXf+6ROTFAO4XDZ58iOfiNceN1g53t1/9tFvB6ndycuZNgR4D4cQOxAXGqXt22h0O15Z82SPw5Gj05ZenXfw7YO46xm9FHAPshkHpUf5MmaMef/Dy2OmOznZyurczBO77BcdifTQCLODv4IHfVlar6HtaxSgtWyC26E2Ds/DzujBvzTiMF9gz52H4HsQP6huOoRjz4J5BFiJ0whlE56Fb6xX7N+Rw9xW4fx/wkmpMxJXXLArH/Dl0ZXyKTjO/wZA7xhEsJpZtii7Y6+F2dMGc6wP2TbdX+NkQPFfbEd93LQyQAsbjy9AD+3ara0W8XyPzWK2eIE10ETP3mgFg/0EQcTfzqPHdEGGxwj8II/eqi34pTLLolVI/pvkPS77yZ58BWJv5+LbNoNo20s+2BWhOzM/+PwpvSSe/VwUA \ No newline at end of file diff --git a/.github/workflows/apply-project-lifecycle-history-pr.yml b/.github/workflows/apply-project-lifecycle-history-pr.yml deleted file mode 100644 index c23b540b0..000000000 --- a/.github/workflows/apply-project-lifecycle-history-pr.yml +++ /dev/null @@ -1,223 +0,0 @@ -name: Apply project lifecycle history on PR - -on: - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: write - -concurrency: - group: apply-project-lifecycle-history-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - red-green-project-history: - if: github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'feat/project-lifecycle-history' - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres - REPAIR_BRANCH: feat/project-lifecycle-history - REPAIR_BASE_SHA: ${{ github.event.pull_request.head.sha }} - steps: - - name: Checkout exact contributor head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: true - fetch-depth: 0 - - - name: Materialize bounded patcher - shell: bash - run: | - cat .github/repair/project_history_payload.b64.* | base64 -d | gzip -d > /tmp/apply_project_lifecycle_history.py - python -m py_compile /tmp/apply_project_lifecycle_history.py - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up locked dependency manager - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - version: "0.11.28" - enable-cache: false - - - name: Select pinned Rust toolchain - run: | - rustup toolchain install 1.97.1 --profile minimal - rustup default 1.97.1 - - - name: Install Python dependencies - run: uv sync --frozen --extra dev --extra backend - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Install frontend dependencies - working-directory: frontend - run: | - corepack enable - pnpm install --frozen-lockfile - - - name: RED - reproduce absent lifecycle contracts - shell: bash - run: | - python /tmp/apply_project_lifecycle_history.py red - set +e - uv run --frozen python -m pytest -q tests/test_project_history.py > /tmp/project-history-python-red.log 2>&1 - python_rc=$? - (cd frontend && pnpm exec vitest run src/components/ProjectHistoryTimeline.test.tsx) > /tmp/project-history-frontend-red.log 2>&1 - frontend_rc=$? - set -e - cat /tmp/project-history-python-red.log - cat /tmp/project-history-frontend-red.log - if [ "$python_rc" -eq 0 ] || [ "$frontend_rc" -eq 0 ]; then - echo "RED unexpectedly passed; refusing to publish an unproven change" >&2 - exit 1 - fi - grep -Eq "ModuleNotFoundError|No module named.*project_history" /tmp/project-history-python-red.log - grep -Eq "Failed to resolve import|Cannot find module|ProjectHistoryTimeline|ProjectHistory" /tmp/project-history-frontend-red.log - git reset --hard HEAD - git clean -fd - - - name: GREEN - materialize normalized authority, API, and Buyer surface - run: python /tmp/apply_project_lifecycle_history.py green - - - name: Refresh and verify universal lock - run: | - uv lock - uv lock --check - uv sync --frozen --extra dev --extra backend - - - name: Verify focused Python and real PostgreSQL contracts - run: | - uv run --frozen python -m pytest -q \ - tests/test_project_history.py \ - tests/test_project_history_backend.py \ - tests/test_project_history_postgres.py \ - tests/test_project_history_schema.py \ - tests/test_project_history_ontology.py - - - name: Require 100 percent changed-module statement and branch coverage - run: | - uv run coverage erase - uv run coverage run --branch -m pytest -q \ - tests/test_project_history.py \ - tests/test_project_history_backend.py \ - tests/test_project_history_postgres.py - uv run coverage report \ - --include='lineageweave/project_history.py,backend/app/project_history.py' \ - --fail-under=100 - - - name: Verify full Python repository and semantic profile - run: | - uv run --frozen python -m pytest -q - uv run --frozen python -m compileall -q lineageweave backend/app scripts - uv run --frozen python - <<'PY' - import ast - from pathlib import Path - from rdflib import Graph - - for path_name in ('lineageweave/project_history.py', 'backend/app/project_history.py'): - module = ast.parse(Path(path_name).read_text(encoding='utf-8')) - missing = [ - node.name - for node in module.body - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) - and not node.name.startswith('_') - and ast.get_docstring(node) is None - ] - if missing: - raise SystemExit(f'{path_name} missing public docstrings: {missing}') - Graph().parse('docs/ontology/project-history-profile.ttl', format='turtle') - PY - - - name: Verify Buyer component, accessibility, build, and Storybook - working-directory: frontend - run: | - pnpm exec vitest run src/components/ProjectHistoryTimeline.test.tsx - pnpm run test - pnpm run lint - pnpm run build - pnpm run build-storybook - - - name: Commit only exact-head verified product changes - shell: bash - run: | - rm -rf frontend/dist frontend/storybook-static - rm -f .github/workflows/apply-project-lifecycle-history.yml - rm -f .github/workflows/apply-project-lifecycle-history-pr.yml - rm -f .github/repair/project_history_payload.b64.* - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- \ - lineageweave/project_history.py \ - backend/app/project_history.py \ - backend/app/main.py \ - migrations/0050_project_history_lifecycle.sql \ - migrations/rollback/0050_project_history_lifecycle.sql \ - docker/postgres-init/Dockerfile \ - docker/postgres-init/migrate.sh \ - scripts/seed_project_history.py \ - Makefile \ - tests/test_project_history.py \ - tests/test_project_history_backend.py \ - tests/test_project_history_postgres.py \ - tests/test_project_history_schema.py \ - tests/test_project_history_ontology.py \ - frontend/src/api.ts \ - frontend/src/App.tsx \ - frontend/src/components/ProjectHistoryTimeline.tsx \ - frontend/src/components/ProjectHistoryTimeline.css \ - frontend/src/components/ProjectHistoryTimeline.test.tsx \ - frontend/src/components/ProjectHistoryTimeline.stories.tsx \ - pyproject.toml \ - uv.lock \ - frontend/package.json \ - CHANGELOG.md \ - CHANGELOG.d/2.20.0-project-lifecycle-history.md \ - ARCHITECTURE.md \ - docs/adr/README.md \ - docs/adr/0100-project-lifecycle-history.md \ - docs/doctoring/PROJECT_LIFECYCLE_HISTORY_REFERENCES.md \ - docs/ontology/project-history-profile.ttl \ - docs/project-lifecycle-history.md \ - docs/storybook-inventory.md - git add -u -- \ - .github/workflows/apply-project-lifecycle-history.yml \ - .github/workflows/apply-project-lifecycle-history-pr.yml \ - .github/repair/project_history_payload.b64.00 \ - .github/repair/project_history_payload.b64.01 \ - .github/repair/project_history_payload.b64.02 \ - .github/repair/project_history_payload.b64.03 - git diff --cached --check - git commit -m "feat(projects): add evidence-bound lifecycle history" - test -z "$(git status --porcelain)" || { - git status --short - exit 1 - } - - git fetch origin "${REPAIR_BRANCH}" - remote_head="$(git rev-parse "origin/${REPAIR_BRANCH}")" - if [ "$remote_head" != "$REPAIR_BASE_SHA" ]; then - echo "branch moved from ${REPAIR_BASE_SHA} to ${remote_head}; refusing unverified publication" >&2 - exit 1 - fi - git push origin "HEAD:${REPAIR_BRANCH}" diff --git a/.github/workflows/apply-project-lifecycle-history.yml b/.github/workflows/apply-project-lifecycle-history.yml deleted file mode 100644 index 8fc6c8ed2..000000000 --- a/.github/workflows/apply-project-lifecycle-history.yml +++ /dev/null @@ -1,223 +0,0 @@ -name: Apply project lifecycle history - -on: - push: - branches: [feat/project-lifecycle-history] - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: apply-project-lifecycle-history - cancel-in-progress: true - -jobs: - red-green-project-history: - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 - env: - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN: postgresql://postgres:postgres@localhost:5432/postgres - REPAIR_BRANCH: feat/project-lifecycle-history - REPAIR_BASE_SHA: ${{ github.sha }} - steps: - - name: Checkout exact branch head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: ${{ github.sha }} - persist-credentials: true - fetch-depth: 0 - - - name: Materialize bounded patcher - shell: bash - run: | - cat .github/repair/project_history_payload.b64.* | base64 -d | gzip -d > /tmp/apply_project_lifecycle_history.py - python -m py_compile /tmp/apply_project_lifecycle_history.py - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up locked dependency manager - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - version: "0.11.28" - enable-cache: false - - - name: Select pinned Rust toolchain - run: | - rustup toolchain install 1.97.1 --profile minimal - rustup default 1.97.1 - - - name: Install Python dependencies - run: uv sync --frozen --extra dev --extra backend - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Install frontend dependencies - working-directory: frontend - run: | - corepack enable - pnpm install --frozen-lockfile - - - name: RED - reproduce absent lifecycle contracts - shell: bash - run: | - python /tmp/apply_project_lifecycle_history.py red - set +e - uv run --frozen python -m pytest -q tests/test_project_history.py > /tmp/project-history-python-red.log 2>&1 - python_rc=$? - (cd frontend && pnpm exec vitest run src/components/ProjectHistoryTimeline.test.tsx) > /tmp/project-history-frontend-red.log 2>&1 - frontend_rc=$? - set -e - cat /tmp/project-history-python-red.log - cat /tmp/project-history-frontend-red.log - if [ "$python_rc" -eq 0 ] || [ "$frontend_rc" -eq 0 ]; then - echo "RED unexpectedly passed; refusing to publish an unproven change" >&2 - exit 1 - fi - grep -Eq "ModuleNotFoundError|No module named.*project_history" /tmp/project-history-python-red.log - grep -Eq "Failed to resolve import|Cannot find module|ProjectHistoryTimeline|ProjectHistory" /tmp/project-history-frontend-red.log - git reset --hard HEAD - git clean -fd - - - name: GREEN - materialize normalized authority, API, and Buyer surface - run: python /tmp/apply_project_lifecycle_history.py green - - - name: Refresh and verify universal lock - run: | - uv lock - uv lock --check - uv sync --frozen --extra dev --extra backend - - - name: Verify focused Python and real PostgreSQL contracts - run: | - uv run --frozen python -m pytest -q \ - tests/test_project_history.py \ - tests/test_project_history_backend.py \ - tests/test_project_history_postgres.py \ - tests/test_project_history_schema.py \ - tests/test_project_history_ontology.py - - - name: Require 100 percent changed-module statement and branch coverage - run: | - uv run coverage erase - uv run coverage run --branch -m pytest -q \ - tests/test_project_history.py \ - tests/test_project_history_backend.py \ - tests/test_project_history_postgres.py - uv run coverage report \ - --include='lineageweave/project_history.py,backend/app/project_history.py' \ - --fail-under=100 - - - name: Verify full Python repository - run: | - uv run --frozen python -m pytest -q - uv run --frozen python -m compileall -q lineageweave backend/app scripts - uv run --frozen python - <<'PY' - import ast - from pathlib import Path - from rdflib import Graph - - for path_name in ( - 'lineageweave/project_history.py', - 'backend/app/project_history.py', - ): - path = Path(path_name) - module = ast.parse(path.read_text(encoding='utf-8')) - missing = [] - for node in module.body: - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - if not node.name.startswith('_') and ast.get_docstring(node) is None: - missing.append(node.name) - if missing: - raise SystemExit(f'{path_name} missing public docstrings: {missing}') - Graph().parse('docs/ontology/project-history-profile.ttl', format='turtle') - PY - - - name: Verify Buyer component, accessibility, build, and Storybook - working-directory: frontend - run: | - pnpm exec vitest run src/components/ProjectHistoryTimeline.test.tsx - pnpm run test - pnpm run lint - pnpm run build - pnpm run build-storybook - - - name: Commit only exact-head verified product changes - shell: bash - run: | - rm -rf frontend/dist frontend/storybook-static - rm -f .github/workflows/apply-project-lifecycle-history.yml - rm -f .github/repair/project_history_payload.b64.* - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- \ - lineageweave/project_history.py \ - backend/app/project_history.py \ - backend/app/main.py \ - migrations/0050_project_history_lifecycle.sql \ - migrations/rollback/0050_project_history_lifecycle.sql \ - docker/postgres-init/Dockerfile \ - docker/postgres-init/migrate.sh \ - scripts/seed_project_history.py \ - Makefile \ - tests/test_project_history.py \ - tests/test_project_history_backend.py \ - tests/test_project_history_postgres.py \ - tests/test_project_history_schema.py \ - tests/test_project_history_ontology.py \ - frontend/src/api.ts \ - frontend/src/App.tsx \ - frontend/src/components/ProjectHistoryTimeline.tsx \ - frontend/src/components/ProjectHistoryTimeline.css \ - frontend/src/components/ProjectHistoryTimeline.test.tsx \ - frontend/src/components/ProjectHistoryTimeline.stories.tsx \ - pyproject.toml \ - uv.lock \ - frontend/package.json \ - CHANGELOG.md \ - CHANGELOG.d/2.20.0-project-lifecycle-history.md \ - ARCHITECTURE.md \ - docs/adr/README.md \ - docs/adr/0100-project-lifecycle-history.md \ - docs/doctoring/PROJECT_LIFECYCLE_HISTORY_REFERENCES.md \ - docs/ontology/project-history-profile.ttl \ - docs/project-lifecycle-history.md \ - docs/storybook-inventory.md - git add -u -- \ - .github/workflows/apply-project-lifecycle-history.yml \ - .github/repair/project_history_payload.b64.00 \ - .github/repair/project_history_payload.b64.01 \ - .github/repair/project_history_payload.b64.02 \ - .github/repair/project_history_payload.b64.03 - git diff --cached --check - git commit -m "feat(projects): add evidence-bound lifecycle history" - test -z "$(git status --porcelain)" || { - git status --short - exit 1 - } - - git fetch origin "${REPAIR_BRANCH}" - remote_head="$(git rev-parse "origin/${REPAIR_BRANCH}")" - if [ "$remote_head" != "$REPAIR_BASE_SHA" ]; then - echo "branch moved from ${REPAIR_BASE_SHA} to ${remote_head}; refusing unverified publication" >&2 - exit 1 - fi - git push origin "HEAD:${REPAIR_BRANCH}" diff --git a/.github/workflows/export-project-history-source.yml b/.github/workflows/export-project-history-source.yml deleted file mode 100644 index 1116bcc9d..000000000 --- a/.github/workflows/export-project-history-source.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Export project history source - -on: - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: read - -concurrency: - group: export-project-history-source-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - export-source: - if: github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'feat/project-history-timeline-v2184-r3' - runs-on: macos-15 - steps: - - name: Checkout exact contributor head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - fetch-depth: 1 - - - name: Archive tracked source without repository metadata - shell: bash - run: | - python3 - <<'PY' - from __future__ import annotations - - import hashlib - import subprocess - import tarfile - from pathlib import Path - - archive = Path("/tmp/lineageweave-project-history-source.tgz") - tracked = subprocess.check_output(["git", "ls-files", "-z"]).split(b"\0") - with tarfile.open(archive, "w:gz") as output: - for raw_path in tracked: - if not raw_path: - continue - path = Path(raw_path.decode("utf-8")) - output.add(path, arcname=path.as_posix(), recursive=False) - digest = hashlib.sha256(archive.read_bytes()).hexdigest() - Path(f"{archive}.sha256").write_text( - f"{digest} {archive.name}\n", - encoding="utf-8", - ) - PY - - - name: Upload exact source artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 - with: - name: lineageweave-project-history-source-${{ github.event.pull_request.head.sha }} - path: | - /tmp/lineageweave-project-history-source.tgz - /tmp/lineageweave-project-history-source.tgz.sha256 - if-no-files-found: error - retention-days: 1 From cfc125cb65f26ed7e834976dbff12b6b9790b59c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:59:11 +0900 Subject: [PATCH 113/118] chore: align project history release version --- CHANGELOG.md | 9 ++++----- frontend/package.json | 2 +- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_documentation_hygiene.py | 31 +++++++++++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 39 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73971fa6e..1daea713f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,13 @@ 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.18.4] - 2026-08-20 +## [2.18.0] - 2026-08-20 ### Added -- Proposed Buyer Project history destination and post-detail entry point for - bounded, authorized exact-project chronology. The feature remains pending - protected-main review and Checks; it does not claim release until those - gates pass (ADR 0111). +- Added a Buyer Project history destination and post-detail entry point for + bounded, authorized exact-project chronology. The release remains pending + protected-main review and Checks (ADR 0111). ## [2.17.0] - 2026-08-19 diff --git a/frontend/package.json b/frontend/package.json index 7a697d0c9..f216081b9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.17.0", + "version": "2.18.0", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 95330cb50..fc9d18b25 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.12.6" +__version__ = "2.18.0" diff --git a/pyproject.toml b/pyproject.toml index c3e956e1e..5f00a4062 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.17.0" +version = "2.18.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_documentation_hygiene.py b/tests/test_documentation_hygiene.py index 6c1e18da9..6fb5d2500 100644 --- a/tests/test_documentation_hygiene.py +++ b/tests/test_documentation_hygiene.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json import re +import tomllib from collections import Counter from pathlib import Path @@ -20,6 +22,35 @@ ) +def test_release_versions_are_consistent() -> None: + """Python, frontend, runtime, and changelog expose one release version.""" + project_version = tomllib.loads((_ROOT / "pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ]["version"] + frontend_version = json.loads( + (_ROOT / "frontend" / "package.json").read_text(encoding="utf-8") + )["version"] + locked_project = next( + package + for package in tomllib.loads((_ROOT / "uv.lock").read_text(encoding="utf-8"))["package"] + if package["name"] == "lineageweave" + ) + runtime_source = (_ROOT / "lineageweave" / "__init__.py").read_text(encoding="utf-8") + runtime_match = re.search(r'^__version__ = "([^"]+)"$', runtime_source, re.MULTILINE) + changelog_source = (_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + changelog_match = re.search(r"^## \[([^]]+)]", changelog_source, re.MULTILINE) + + assert runtime_match is not None + assert changelog_match is not None + assert { + project_version, + frontend_version, + locked_project["version"], + runtime_match.group(1), + changelog_match.group(1), + } == {project_version} + + def test_adr_numbers_are_unique_and_documents_are_not_placeholders() -> None: """Every committed ADR number identifies one substantive UTF-8 document.""" paths = sorted(_ADR_DIRECTORY.glob("*.md")) diff --git a/uv.lock b/uv.lock index 7062700b7..52a3376dd 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.17.0" +version = "2.18.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 0c8e8d0825b02f421cbe4c66fb6defab5c842483 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 12:12:33 +0900 Subject: [PATCH 114/118] fix: preserve project history evidence truth in buyer UI --- ....18.0-project-history-truth-and-loading.md | 5 ++++ frontend/src/App.test.tsx | 24 ++++++++++++++++++- frontend/src/App.tsx | 14 +++++++++-- .../src/components/ProjectHistoryTimeline.css | 10 ++++---- .../ProjectHistoryTimeline.test.tsx | 17 +++++++++++++ .../src/components/ProjectHistoryTimeline.tsx | 7 +++++- frontend/src/projectHistory.ts | 16 +++++++++---- lineageweave/project_history.py | 3 ++- tests/test_project_history.py | 20 ++++++++++++++++ 9 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 CHANGELOG.d/2.18.0-project-history-truth-and-loading.md diff --git a/CHANGELOG.d/2.18.0-project-history-truth-and-loading.md b/CHANGELOG.d/2.18.0-project-history-truth-and-loading.md new file mode 100644 index 000000000..ded0feb87 --- /dev/null +++ b/CHANGELOG.d/2.18.0-project-history-truth-and-loading.md @@ -0,0 +1,5 @@ +### Fixed + +- Project history now labels the actual source-post or document time basis, + keeps evidence-free responsibility gaps unknown, shows a loading status while + history is fetched, and uses the Stylelint-compatible `currentcolor` token. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fc3d64ee9..4376094a3 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -94,6 +94,7 @@ describe("App, authenticated", () => { manyCustomerHints?: number; customerEntityHierarchy?: boolean; deferCustomerRelated?: boolean; + deferProjectHistory?: boolean; boardPosts?: { post_id: string; post_title: string; @@ -128,6 +129,7 @@ describe("App, authenticated", () => { releaseGroupRelated: () => void; releaseDemoRelated: () => void; releasePostOneSummary: () => void; + releaseProjectHistory: () => void; } { const statusLabel: Record = { open: "Open", @@ -184,6 +186,12 @@ describe("App, authenticated", () => { releasePostOneSummary = resolve; }) : Promise.resolve(); + let releaseProjectHistory = () => {}; + const projectHistoryReady = options?.deferProjectHistory + ? new Promise((resolve) => { + releaseProjectHistory = resolve; + }) + : Promise.resolve(); const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -1800,7 +1808,7 @@ describe("App, authenticated", () => { } if (url.includes("/api/project-history?") && method === "GET") { projectHistoryRequestUrl = url; - return Promise.resolve( + return projectHistoryReady.then(() => jsonResponse({ contract_version: 1, project_key: "Semantic project", @@ -1845,6 +1853,7 @@ describe("App, authenticated", () => { releaseGroupRelated, releaseDemoRelated, releasePostOneSummary, + releaseProjectHistory, }); } @@ -2036,6 +2045,19 @@ describe("App, authenticated", () => { expect(projectHistoryRequestUrl).toContain("focus_post_id=post-1"); }); + it("shows a next-action loading state while project history is requested", async () => { + const fetchMock = stubBackend({ deferProjectHistory: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click( + await screen.findByRole("button", { name: "Open project history for: Semantic project" }), + ); + + expect(await screen.findByText("Loading project history...")).toBeInTheDocument(); + fetchMock.releaseProjectHistory(); + expect(await screen.findByRole("heading", { name: "Project event timeline" })).toBeInTheDocument(); + }); + it("clicking Weekly VOC keeps the 2026-W01 Voice of Customer post and names Event Lineage as the next action", async () => { stubBackend({ boardPosts: [ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eadbf03f4..1da0269b1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4836,6 +4836,7 @@ function ProjectHistoryPanel({ const [selectedProjectKey, setSelectedProjectKey] = useState(""); const [projection, setProjection] = useState(null); const [error, setError] = useState(false); + const [loadingHistory, setLoadingHistory] = useState(false); const locale = useLocale(); const historyRequest = useRef(0); @@ -4872,19 +4873,25 @@ function ProjectHistoryPanel({ useEffect(() => { if (!selectedProjectKey || !index?.knowledge_cutoff) { setProjection(null); + setLoadingHistory(false); return; } const request = ++historyRequest.current; setProjection(null); setError(false); + setLoadingHistory(true); const focusPostId = initialProjectKey === selectedProjectKey ? initialFocusPostId ?? undefined : undefined; fetchProjectHistory(accessToken, selectedProjectKey, index.knowledge_cutoff, focusPostId) .then((result) => { - if (request === historyRequest.current) setProjection(result); + if (request !== historyRequest.current) return; + setProjection(result); + setLoadingHistory(false); }) .catch(() => { - if (request === historyRequest.current) setError(true); + if (request !== historyRequest.current) return; + setError(true); + setLoadingHistory(false); }); }, [accessToken, index?.knowledge_cutoff, initialFocusPostId, initialProjectKey, selectedProjectKey]); @@ -4918,6 +4925,9 @@ function ProjectHistoryPanel({ ) : null} + {loadingHistory && !error ? ( +

{projectHistoryText(locale, "loadingHistory")}

+ ) : null} {projection ? : null}
); diff --git a/frontend/src/components/ProjectHistoryTimeline.css b/frontend/src/components/ProjectHistoryTimeline.css index 0ff7a031e..6d9424bb7 100644 --- a/frontend/src/components/ProjectHistoryTimeline.css +++ b/frontend/src/components/ProjectHistoryTimeline.css @@ -26,7 +26,7 @@ .project-history-warning, .project-history-boundary { - border-inline-start: 0.25rem solid currentColor; + border-inline-start: 0.25rem solid currentcolor; padding-inline-start: 0.75rem; } @@ -62,15 +62,15 @@ } .project-history-tab:focus-visible { - outline: 3px solid currentColor; + outline: 3px solid currentcolor; outline-offset: 0.25rem; } .project-history-marker { - background: currentColor; + background: currentcolor; border: 0.25rem solid var(--surface-color, #fff); border-radius: 50%; - box-shadow: 0 0 0 2px currentColor; + box-shadow: 0 0 0 2px currentcolor; height: 1rem; width: 1rem; z-index: 1; @@ -156,7 +156,7 @@ } .project-history-truth { - border: 1px solid currentColor; + border: 1px solid currentcolor; border-radius: 999px; font-size: 0.8rem; padding: 0.1rem 0.5rem; diff --git a/frontend/src/components/ProjectHistoryTimeline.test.tsx b/frontend/src/components/ProjectHistoryTimeline.test.tsx index ec626620c..316a5517b 100644 --- a/frontend/src/components/ProjectHistoryTimeline.test.tsx +++ b/frontend/src/components/ProjectHistoryTimeline.test.tsx @@ -150,4 +150,21 @@ describe("ProjectHistoryTimeline", () => { expect(screen.getByText("post_summary_role")).toBeInTheDocument(); expect(screen.queryByText("Observed award owner")).not.toBeInTheDocument(); }); + + it("labels the projection's actual time basis", () => { + const { rerender } = render( + , + ); + expect( + screen.getByText("Dates use source-post creation time because a separate event clock is not recorded."), + ).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("Dates use the recorded document time.")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/ProjectHistoryTimeline.tsx b/frontend/src/components/ProjectHistoryTimeline.tsx index bd6e1cbe6..a7832716a 100644 --- a/frontend/src/components/ProjectHistoryTimeline.tsx +++ b/frontend/src/components/ProjectHistoryTimeline.tsx @@ -107,7 +107,12 @@ export function ProjectHistoryTimeline({

-

{projectHistoryText(locale, "documentTime")}

+

+ {projectHistoryText( + locale, + projection.time_basis_code === "document_time" ? "documentTime" : "sourcePostTime", + )} +

{projection.evidence_boundary_code ? (

{projectHistoryText(locale, "evidenceBoundary")} diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts index c7660e0b2..d60fec17f 100644 --- a/frontend/src/projectHistory.ts +++ b/frontend/src/projectHistory.ts @@ -160,6 +160,7 @@ const MESSAGE_KEYS = [ "evidenceBoundary", "heading", "summaryCounts", + "sourcePostTime", "documentTime", "truncated", "eventDetail", @@ -213,7 +214,8 @@ const EN: Record = { evidenceBoundary: "Only source posts that pass the current permission, visibility, publication, and cutoff gates are included.", heading: "Project event timeline", summaryCounts: "{events} events · {actors} actors in evidence", - documentTime: "Dates use source-post creation time because a separate event clock is not recorded.", + sourcePostTime: "Dates use source-post creation time because a separate event clock is not recorded.", + documentTime: "Dates use the recorded document time.", truncated: "This bounded timeline is truncated. The selected event remains included.", eventDetail: "Event detail", eventType: "Display event type", @@ -263,7 +265,8 @@ const MESSAGES: Record> = { evidenceBoundary: "현재 권한·공개 범위·게시 상태·기준 시각을 통과한 원천 게시물만 포함합니다.", heading: "프로젝트 이벤트 타임라인", summaryCounts: "이벤트 {events}건 · 근거에 등장한 담당자 {actors}명", - documentTime: "별도 사건 시각이 없어 날짜는 원천 게시물 생성 시각을 사용합니다.", + sourcePostTime: "별도 사건 시각이 없어 날짜는 원천 게시물 생성 시각을 사용합니다.", + documentTime: "날짜는 기록된 문서 시각을 사용합니다.", truncated: "이 제한된 타임라인은 일부만 표시합니다. 선택한 이벤트는 계속 포함됩니다.", eventDetail: "이벤트 상세", eventType: "표시용 이벤트 유형", @@ -310,7 +313,8 @@ const MESSAGES: Record> = { evidenceBoundary: "仅包含通过当前权限、可见性、发布状态和截止时间检查的源帖子。", heading: "项目事件时间线", summaryCounts: "{events} 个事件 · 依据中出现 {actors} 名责任人", - documentTime: "未记录独立事件时钟,因此日期采用源帖子创建时间。", + sourcePostTime: "未记录独立事件时钟,因此日期采用源帖子创建时间。", + documentTime: "日期采用记录的文档时间。", truncated: "此有界时间线已截断,但所选事件仍保留。", eventDetail: "事件详情", eventType: "显示事件类型", @@ -357,7 +361,8 @@ const MESSAGES: Record> = { evidenceBoundary: "現在の権限・可視性・公開状態・基準時刻を通過した原資料だけを含みます。", heading: "プロジェクトイベントのタイムライン", summaryCounts: "イベント {events}件 · 根拠内の担当者 {actors}名", - documentTime: "独立したイベント時刻がないため、原資料の作成時刻を使用します。", + sourcePostTime: "独立したイベント時刻がないため、原資料の作成時刻を使用します。", + documentTime: "日付は記録された文書時刻を使用します。", truncated: "この上限付きタイムラインは省略されていますが、選択イベントは保持されます。", eventDetail: "イベント詳細", eventType: "表示用イベント種別", @@ -404,7 +409,8 @@ const MESSAGES: Record> = { evidenceBoundary: "Chỉ bao gồm bài nguồn vượt qua quyền, khả năng hiển thị, trạng thái xuất bản và mốc thời gian hiện tại.", heading: "Dòng thời gian sự kiện dự án", summaryCounts: "{events} sự kiện · {actors} người xuất hiện trong bằng chứng", - documentTime: "Không có đồng hồ sự kiện riêng, nên dùng thời gian tạo bài nguồn.", + sourcePostTime: "Không có đồng hồ sự kiện riêng, nên dùng thời gian tạo bài nguồn.", + documentTime: "Ngày sử dụng thời gian tài liệu được ghi nhận.", truncated: "Dòng thời gian có giới hạn này đã bị rút gọn nhưng vẫn giữ sự kiện đang chọn.", eventDetail: "Chi tiết sự kiện", eventType: "Loại sự kiện hiển thị", diff --git a/lineageweave/project_history.py b/lineageweave/project_history.py index 7f2535d96..29ce504be 100644 --- a/lineageweave/project_history.py +++ b/lineageweave/project_history.py @@ -440,7 +440,8 @@ def build_project_history_projection( transition_truth = None if transition is not None: combined_truth = (previous_truth or set()) | current_truth - transition_truth = "inferred" if "inferred" in combined_truth else "observed" + if combined_truth: + transition_truth = "inferred" if "inferred" in combined_truth else "observed" evidence = roles_by_event[event_id] events.append( { diff --git a/tests/test_project_history.py b/tests/test_project_history.py index 9c8a38ecd..70b750b24 100644 --- a/tests/test_project_history.py +++ b/tests/test_project_history.py @@ -228,6 +228,26 @@ def test_summary_responsibilities_remain_inferred_evidence() -> None: assert role["provenance"] == "post_summary_role" +def test_assignment_gap_without_role_evidence_has_no_truth_status() -> None: + """An empty adjacent evidence pair remains unknown, never observed.""" + + first = _event_row("00000000-0000-0000-0000-000000000001") + second = _event_row("00000000-0000-0000-0000-000000000002") + second["created_at"] = datetime(2022, 3, 12, 9, tzinfo=timezone.utc) + projection = build_project_history_projection( + project_key="P-100", + focus_event_id=second["post_id"], + event_rows=[first, second], + match_rows=[], + role_rows=[], + edge_rows=[], + ) + + transition = projection["events"][1] + assert transition["responsibility_transition_code"] == "assignment_gap" + assert transition["responsibility_transition_truth_status_code"] is None + + def test_project_history_connection_protocol_fails_explicitly() -> None: """The protocol default is not an executable ellipsis/no-op.""" From 6a35ca8489bccfaa19a8b68fbc6cdae6cc24e34e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 12:25:21 +0900 Subject: [PATCH 115/118] fix: bound and explain project history loading --- backend/app/project_history.py | 41 ++++++++++++++++--- docker/postgres-init/migrate.sh | 2 +- ...uthorized-project-history-buyer-surface.md | 7 ++++ docs/product-technical-gap-baseline.md | 6 +-- frontend/src/App.test.tsx | 24 ++++++++++- frontend/src/App.tsx | 14 ++++++- .../src/components/ProjectHistoryTimeline.css | 10 ++--- .../ProjectHistoryTimeline.test.tsx | 12 ++++++ .../src/components/ProjectHistoryTimeline.tsx | 7 +++- frontend/src/projectHistory.ts | 6 +++ lineageweave/project_history.py | 3 +- migrations/0053_project_history_lookup.sql | 40 ++++++++++++++++++ .../rollback/0053_project_history_lookup.sql | 10 +++++ tests/test_migration_replay.py | 22 ++++++++++ tests/test_project_history.py | 40 +++++++++++++++++- 15 files changed, 222 insertions(+), 22 deletions(-) create mode 100644 migrations/0053_project_history_lookup.sql create mode 100644 migrations/rollback/0053_project_history_lookup.sql diff --git a/backend/app/project_history.py b/backend/app/project_history.py index bdb51d407..1ac74422d 100644 --- a/backend/app/project_history.py +++ b/backend/app/project_history.py @@ -11,6 +11,7 @@ from lineageweave.project_history import ( PROJECT_HISTORY_CONTRACT_VERSION, PROJECT_HISTORY_TIME_BASIS, + _as_utc, build_project_history_projection, normalize_project_key, ) @@ -19,6 +20,8 @@ PROJECT_HISTORY_MAXIMUM_LIMIT = 128 PROJECT_INDEX_DEFAULT_LIMIT = 100 PROJECT_INDEX_MAXIMUM_LIMIT = 200 +PROJECT_INDEX_MINIMUM_SOURCE_POST_LIMIT = 1024 +PROJECT_INDEX_STATEMENT_TIMEOUT_MILLISECONDS = 5000 class ProjectHistoryConnection(Protocol): @@ -195,16 +198,33 @@ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]: order by edge.child_post_id, edge.parent_post_id """ _INDEX_SQL = f""" -with visible_post as materialized ( +with query_timeout as materialized ( + select set_config( + 'statement_timeout', + '{PROJECT_INDEX_STATEMENT_TIMEOUT_MILLISECONDS}', + true + ) +), recent_visible_post as materialized ( select post.post_id, post.created_at, {_SOURCE_CODE} as source_project_code, {_SOURCE_NAME} as source_project_name from source_post post + cross join query_timeout where (post.visibility_code = 'public' or post.corporate_entity_id::text = any($1::text[])) and {_ELIGIBILITY} and post.created_at <= $2 + order by post.created_at desc, post.post_id desc + limit ($4 + 1) +), visible_post as materialized ( + select post_id, created_at, source_project_code, source_project_name + from recent_visible_post + order by created_at desc, post_id desc + limit $4 +), source_scan as ( + select count(*) > $4 as source_scan_truncated + from recent_visible_post ), project_evidence as ( select visible_post.post_id, visible_post.created_at, @@ -250,8 +270,10 @@ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]: project_name, truth_status_code, event_count, - latest_event_at + latest_event_at, + source_scan.source_scan_truncated from project_group + cross join source_scan order by latest_event_at desc, project_name, project_key, normalized_project_key limit $3 """ @@ -291,15 +313,22 @@ async def fetch_project_history_index( if limit < 1 or limit > PROJECT_INDEX_MAXIMUM_LIMIT: raise ValueError("project index limit is outside the supported bound") _require_aware_cutoff(knowledge_cutoff) + source_post_limit = max( + PROJECT_INDEX_MINIMUM_SOURCE_POST_LIMIT, + (limit + 1) * PROJECT_HISTORY_DEFAULT_LIMIT, + ) rows = list( await conn.fetch( _INDEX_SQL, list(corporate_entity_ids), knowledge_cutoff, limit + 1, + source_post_limit, ) ) - truncated = len(rows) > limit + truncated = len(rows) > limit or any( + bool(row["source_scan_truncated"]) for row in rows + ) projects = [ { "normalized_project_key": str(row["normalized_project_key"]), @@ -307,14 +336,14 @@ async def fetch_project_history_index( "project_name": str(row["project_name"]), "truth_status_code": str(row["truth_status_code"]), "event_count": int(row["event_count"]), - "latest_event_at": row["latest_event_at"].isoformat(), + "latest_event_at": _as_utc(row["latest_event_at"]), } for row in rows[:limit] ] return { "contract_version": PROJECT_HISTORY_CONTRACT_VERSION, "time_basis_code": PROJECT_HISTORY_TIME_BASIS, - "knowledge_cutoff": knowledge_cutoff.isoformat(), + "knowledge_cutoff": _as_utc(knowledge_cutoff), "project_count": len(projects), "truncated": truncated, "projects": projects, @@ -388,7 +417,7 @@ async def fetch_project_history_projection( edge_rows=edge_rows, truncated=truncated, ) - projection["knowledge_cutoff"] = knowledge_cutoff.isoformat() + projection["knowledge_cutoff"] = _as_utc(knowledge_cutoff) projection["evidence_boundary_code"] = "authorized_visible_source_posts" return projection diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 122523d18..af0fc9bba 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0051_*|0052_*) ;; + 0051_*|0052_*|0053_*) ;; 0060_*|0100_*) ;; *) continue ;; esac diff --git a/docs/adr/0111-authorized-project-history-buyer-surface.md b/docs/adr/0111-authorized-project-history-buyer-surface.md index bf694b727..1c81a4606 100644 --- a/docs/adr/0111-authorized-project-history-buyer-surface.md +++ b/docs/adr/0111-authorized-project-history-buyer-surface.md @@ -22,6 +22,11 @@ Add a bounded project index and project-history read model behind the existing checks. Normalize exact project identities with the same Unicode-compatible key on both reads. Apply the knowledge cutoff before selecting event IDs, then constrain matches, roles, and lineage paths to that authorized ID set. +The project index first bounds its input to the newest authorized source rows, +marks the response truncated when that bound is reached, and applies a local +five-second PostgreSQL statement timeout. Expression and recency indexes support +the bounded list and exact-detail paths; forward and rollback migrations remain +symmetric. All response clocks use canonical UTC RFC 3339 `Z` serialization. Expose the read model through the Buyer `Project history` destination and the post-detail project-evidence card. Both entry points use the same @@ -44,6 +49,8 @@ boundary states; no second component-specific token system is introduced. source project identity. - The current document-time fallback remains explicit until a durable event clock is introduced. +- The project chooser is a bounded recent-project view, not an unbounded catalog + export; buyers are told when its source or display limit is reached. - A future customer-master graph may reuse the projection pattern, but this ADR deliberately does not invent organization roles or temporal facts that are absent from persisted evidence. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 06f5a8dd2..9f181c446 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -9,12 +9,12 @@ explicit WAI-ARIA ownership; final-head hosted Checks and independent approval r requirements, technical contracts, implementation evidence, and active PRs. An active PR is proposed work, not shipped behavior. -## Exact-head checkpoint (2026-08-20 19:14 Asia/Seoul) +## Historical exact-head checkpoint (2026-08-20 19:14 Asia/Seoul) The following is the current GitHub observation used for this branch. It supersedes the historical 17:08 snapshot and does not claim protected-main behavior. GitHub reports 24 open PRs from #190 through #309; none of the -#258-and-later stack has an independent `APPROVED` review at this checkpoint. +`#258`-and-later stack has an independent `APPROVED` review at this checkpoint. | PR group | Exact observed heads | Merge observation | |---|---|---| @@ -32,7 +32,7 @@ timeline entry point, Storybook-compatible truth rendering, and live PostgreSQL/API regressions. The final exact head and Checks must be recorded after the ordinary push. -## Exact-head refresh (2026-08-20 19:50 Asia/Seoul) +## Historical exact-head refresh (2026-08-20 19:50 Asia/Seoul) This refresh supersedes the 19:14 checkpoint for the PRs it names. It records GitHub observations, not protected-main behavior. The repository has 25 open diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a992c86ae..3f29ef193 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -89,6 +89,7 @@ describe("App, authenticated", () => { deferMe?: boolean; deferPostOneSummary?: boolean; deferSecondAsk?: boolean; + deferProjectHistory?: boolean; invalidAskSessionOnce?: boolean; meFailed?: boolean; postBody?: string; @@ -129,6 +130,7 @@ describe("App, authenticated", () => { releaseGroupRelated: () => void; releaseDemoRelated: () => void; releasePostOneSummary: () => void; + releaseProjectHistory: () => void; } { const statusLabel: Record = { open: "Open", @@ -185,6 +187,12 @@ describe("App, authenticated", () => { releasePostOneSummary = resolve; }) : Promise.resolve(); + let releaseProjectHistory = () => {}; + const projectHistoryReady = options?.deferProjectHistory + ? new Promise((resolve) => { + releaseProjectHistory = resolve; + }) + : Promise.resolve(); const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -1811,7 +1819,7 @@ describe("App, authenticated", () => { } if (url.includes("/api/project-history?") && method === "GET") { projectHistoryRequestUrl = url; - return Promise.resolve( + return projectHistoryReady.then(() => jsonResponse({ contract_version: 1, project_key: "Semantic project", @@ -1856,6 +1864,7 @@ describe("App, authenticated", () => { releaseGroupRelated, releaseDemoRelated, releasePostOneSummary, + releaseProjectHistory, }); } @@ -2067,6 +2076,19 @@ describe("App, authenticated", () => { expect(projectHistoryRequestUrl).toContain("focus_post_id=post-1"); }); + it("shows project-history progress until the selected projection arrives", async () => { + const fetchMock = stubBackend({ deferProjectHistory: true }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click( + await screen.findByRole("button", { name: "Open project history for: Semantic project" }), + ); + + expect(await screen.findByRole("status")).toHaveTextContent("Loading project history..."); + fetchMock.releaseProjectHistory(); + expect(await screen.findByRole("heading", { name: "Project event timeline" })).toBeInTheDocument(); + }); + it("clicking Weekly VOC keeps the 2026-W01 Voice of Customer post and names Event Lineage as the next action", async () => { stubBackend({ boardPosts: [ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 64ee87215..dd050c8c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4677,6 +4677,7 @@ function ProjectHistoryPanel({ const [index, setIndex] = useState(null); const [selectedProjectKey, setSelectedProjectKey] = useState(""); const [projection, setProjection] = useState(null); + const [loadingHistory, setLoadingHistory] = useState(false); const [error, setError] = useState(false); const locale = useLocale(); const historyRequest = useRef(0); @@ -4714,19 +4715,27 @@ function ProjectHistoryPanel({ useEffect(() => { if (!selectedProjectKey || !index?.knowledge_cutoff) { setProjection(null); + setLoadingHistory(false); return; } const request = ++historyRequest.current; setProjection(null); + setLoadingHistory(true); setError(false); const focusPostId = initialProjectKey === selectedProjectKey ? initialFocusPostId ?? undefined : undefined; fetchProjectHistory(accessToken, selectedProjectKey, index.knowledge_cutoff, focusPostId) .then((result) => { - if (request === historyRequest.current) setProjection(result); + if (request === historyRequest.current) { + setProjection(result); + setLoadingHistory(false); + } }) .catch(() => { - if (request === historyRequest.current) setError(true); + if (request === historyRequest.current) { + setError(true); + setLoadingHistory(false); + } }); }, [accessToken, index?.knowledge_cutoff, initialFocusPostId, initialProjectKey, selectedProjectKey]); @@ -4736,6 +4745,7 @@ function ProjectHistoryPanel({

{projectHistoryText(locale, "destinationIntro")}

{error ?

{projectHistoryText(locale, "historyUnavailable")}

: null} {index === null ?

{projectHistoryText(locale, "loadingProjects")}

: null} + {loadingHistory ?

{projectHistoryText(locale, "loadingHistory")}

: null} {index?.projects.length === 0 && !error ? (

{projectHistoryText(locale, "noProjects")}

) : null} diff --git a/frontend/src/components/ProjectHistoryTimeline.css b/frontend/src/components/ProjectHistoryTimeline.css index 0ff7a031e..6d9424bb7 100644 --- a/frontend/src/components/ProjectHistoryTimeline.css +++ b/frontend/src/components/ProjectHistoryTimeline.css @@ -26,7 +26,7 @@ .project-history-warning, .project-history-boundary { - border-inline-start: 0.25rem solid currentColor; + border-inline-start: 0.25rem solid currentcolor; padding-inline-start: 0.75rem; } @@ -62,15 +62,15 @@ } .project-history-tab:focus-visible { - outline: 3px solid currentColor; + outline: 3px solid currentcolor; outline-offset: 0.25rem; } .project-history-marker { - background: currentColor; + background: currentcolor; border: 0.25rem solid var(--surface-color, #fff); border-radius: 50%; - box-shadow: 0 0 0 2px currentColor; + box-shadow: 0 0 0 2px currentcolor; height: 1rem; width: 1rem; z-index: 1; @@ -156,7 +156,7 @@ } .project-history-truth { - border: 1px solid currentColor; + border: 1px solid currentcolor; border-radius: 999px; font-size: 0.8rem; padding: 0.1rem 0.5rem; diff --git a/frontend/src/components/ProjectHistoryTimeline.test.tsx b/frontend/src/components/ProjectHistoryTimeline.test.tsx index ec626620c..4dc8c17c5 100644 --- a/frontend/src/components/ProjectHistoryTimeline.test.tsx +++ b/frontend/src/components/ProjectHistoryTimeline.test.tsx @@ -120,6 +120,18 @@ const projection: ProjectHistoryProjection = { }; describe("ProjectHistoryTimeline", () => { + it("describes a recorded document clock without calling it a source-post fallback", () => { + render( + , + ); + + expect(screen.getByText(/dates use the document time recorded by the source/i)).toBeInTheDocument(); + expect(screen.queryByText(/separate event clock is not recorded/i)).not.toBeInTheDocument(); + }); + it("shows the focus event, evidence gap, authorization boundary, and non-causal prior history", () => { const onOpenPost = vi.fn(); render(); diff --git a/frontend/src/components/ProjectHistoryTimeline.tsx b/frontend/src/components/ProjectHistoryTimeline.tsx index bd6e1cbe6..23644c88b 100644 --- a/frontend/src/components/ProjectHistoryTimeline.tsx +++ b/frontend/src/components/ProjectHistoryTimeline.tsx @@ -107,7 +107,12 @@ export function ProjectHistoryTimeline({

-

{projectHistoryText(locale, "documentTime")}

+

+ {projectHistoryText( + locale, + projection.time_basis_code === "document_time" ? "recordedDocumentTime" : "documentTime", + )} +

{projection.evidence_boundary_code ? (

{projectHistoryText(locale, "evidenceBoundary")} diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts index c7660e0b2..24c9f9997 100644 --- a/frontend/src/projectHistory.ts +++ b/frontend/src/projectHistory.ts @@ -161,6 +161,7 @@ const MESSAGE_KEYS = [ "heading", "summaryCounts", "documentTime", + "recordedDocumentTime", "truncated", "eventDetail", "eventType", @@ -214,6 +215,7 @@ const EN: Record = { heading: "Project event timeline", summaryCounts: "{events} events · {actors} actors in evidence", documentTime: "Dates use source-post creation time because a separate event clock is not recorded.", + recordedDocumentTime: "Dates use the document time recorded by the source.", truncated: "This bounded timeline is truncated. The selected event remains included.", eventDetail: "Event detail", eventType: "Display event type", @@ -264,6 +266,7 @@ const MESSAGES: Record> = { heading: "프로젝트 이벤트 타임라인", summaryCounts: "이벤트 {events}건 · 근거에 등장한 담당자 {actors}명", documentTime: "별도 사건 시각이 없어 날짜는 원천 게시물 생성 시각을 사용합니다.", + recordedDocumentTime: "날짜는 원천에 기록된 문서 시각을 사용합니다.", truncated: "이 제한된 타임라인은 일부만 표시합니다. 선택한 이벤트는 계속 포함됩니다.", eventDetail: "이벤트 상세", eventType: "표시용 이벤트 유형", @@ -311,6 +314,7 @@ const MESSAGES: Record> = { heading: "项目事件时间线", summaryCounts: "{events} 个事件 · 依据中出现 {actors} 名责任人", documentTime: "未记录独立事件时钟,因此日期采用源帖子创建时间。", + recordedDocumentTime: "日期采用来源记录的文档时间。", truncated: "此有界时间线已截断,但所选事件仍保留。", eventDetail: "事件详情", eventType: "显示事件类型", @@ -358,6 +362,7 @@ const MESSAGES: Record> = { heading: "プロジェクトイベントのタイムライン", summaryCounts: "イベント {events}件 · 根拠内の担当者 {actors}名", documentTime: "独立したイベント時刻がないため、原資料の作成時刻を使用します。", + recordedDocumentTime: "日付には原資料に記録された文書時刻を使用します。", truncated: "この上限付きタイムラインは省略されていますが、選択イベントは保持されます。", eventDetail: "イベント詳細", eventType: "表示用イベント種別", @@ -405,6 +410,7 @@ const MESSAGES: Record> = { heading: "Dòng thời gian sự kiện dự án", summaryCounts: "{events} sự kiện · {actors} người xuất hiện trong bằng chứng", documentTime: "Không có đồng hồ sự kiện riêng, nên dùng thời gian tạo bài nguồn.", + recordedDocumentTime: "Ngày dùng thời gian tài liệu do nguồn ghi nhận.", truncated: "Dòng thời gian có giới hạn này đã bị rút gọn nhưng vẫn giữ sự kiện đang chọn.", eventDetail: "Chi tiết sự kiện", eventType: "Loại sự kiện hiển thị", diff --git a/lineageweave/project_history.py b/lineageweave/project_history.py index 7f2535d96..29ce504be 100644 --- a/lineageweave/project_history.py +++ b/lineageweave/project_history.py @@ -440,7 +440,8 @@ def build_project_history_projection( transition_truth = None if transition is not None: combined_truth = (previous_truth or set()) | current_truth - transition_truth = "inferred" if "inferred" in combined_truth else "observed" + if combined_truth: + transition_truth = "inferred" if "inferred" in combined_truth else "observed" evidence = roles_by_event[event_id] events.append( { diff --git a/migrations/0053_project_history_lookup.sql b/migrations/0053_project_history_lookup.sql new file mode 100644 index 000000000..75e78d786 --- /dev/null +++ b/migrations/0053_project_history_lookup.sql @@ -0,0 +1,40 @@ +begin; + +-- Bound the project index by the newest authorized source rows before its +-- normalization/window/group stages, and support exact project lookups. +create index if not exists source_post_project_history_recent_idx + on source_post (created_at desc, post_id desc); + +create index if not exists source_post_project_code_history_idx + on source_post ( + lower(normalize(btrim(source_project_code), NFKC)), + created_at, + post_id + ) + where source_project_code is not null and btrim(source_project_code) <> ''; + +create index if not exists source_post_project_name_history_idx + on source_post ( + lower(normalize(btrim(source_project_name), NFKC)), + created_at, + post_id + ) + where source_project_name is not null and btrim(source_project_name) <> ''; + +create index if not exists post_project_mention_key_history_idx + on post_project_mention ( + lower(normalize(btrim(project_key), NFKC)), + post_id + ); + +create index if not exists post_project_mention_name_history_idx + on post_project_mention ( + lower(normalize(btrim(project_name), NFKC)), + post_id + ); + +create index if not exists post_lineage_edge_child_history_idx + on post_lineage_edge (child_post_id, parent_post_id) + include (fused_score); + +commit; diff --git a/migrations/rollback/0053_project_history_lookup.sql b/migrations/rollback/0053_project_history_lookup.sql new file mode 100644 index 000000000..03a9d6751 --- /dev/null +++ b/migrations/rollback/0053_project_history_lookup.sql @@ -0,0 +1,10 @@ +begin; + +drop index if exists post_lineage_edge_child_history_idx; +drop index if exists post_project_mention_name_history_idx; +drop index if exists post_project_mention_key_history_idx; +drop index if exists source_post_project_name_history_idx; +drop index if exists source_post_project_code_history_idx; +drop index if exists source_post_project_history_recent_idx; + +commit; diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 29098f0a5..6bccf9d41 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -56,3 +56,25 @@ def test_migrate_sh_replays_global_ask_context_migration() -> None: ).read_text(encoding="utf-8") assert "0052_*" in script + + +def test_migrate_sh_replays_project_history_lookup_indexes() -> None: + """Existing volumes receive the bounded project-history lookup indexes.""" + root = Path(__file__).resolve().parents[1] + script = (root / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") + forward = (root / "migrations/0053_project_history_lookup.sql").read_text(encoding="utf-8") + rollback = (root / "migrations/rollback/0053_project_history_lookup.sql").read_text( + encoding="utf-8" + ) + + assert "0053_*" in script + for index_name in ( + "source_post_project_history_recent_idx", + "source_post_project_code_history_idx", + "source_post_project_name_history_idx", + "post_project_mention_key_history_idx", + "post_project_mention_name_history_idx", + "post_lineage_edge_child_history_idx", + ): + assert f"create index if not exists {index_name}" in forward + assert f"drop index if exists {index_name}" in rollback diff --git a/tests/test_project_history.py b/tests/test_project_history.py index 9c8a38ecd..83c252df4 100644 --- a/tests/test_project_history.py +++ b/tests/test_project_history.py @@ -46,6 +46,7 @@ def __init__(self, rows=None) -> None: "truth_status_code": "observed", "event_count": 2, "latest_event_at": datetime(2026, 1, 2, tzinfo=timezone.utc), + "source_scan_truncated": False, } ] self.calls: list[tuple[str, tuple[object, ...]]] = [] @@ -117,6 +118,25 @@ def test_responsibility_transition_describes_document_evidence_only() -> None: assert responsibility_transition_code(["person:a"], []) == "assignment_gap" +def test_assignment_gap_without_role_evidence_has_no_truth_status() -> None: + """Two empty role sets must not manufacture an observed assignment fact.""" + second = _event_row("00000000-0000-0000-0000-000000000002") + second["created_at"] = datetime(2022, 3, 12, 9, tzinfo=timezone.utc) + + projection = build_project_history_projection( + project_key="P-100", + focus_event_id=None, + event_rows=[_event_row(), second], + match_rows=[], + role_rows=[], + edge_rows=[], + ) + + transition = projection["events"][1] + assert transition["responsibility_transition_code"] == "assignment_gap" + assert transition["responsibility_transition_truth_status_code"] is None + + def test_matching_observed_project_code_keeps_its_distinct_display_name() -> None: """A matching code may carry a human display name that is not itself the key.""" @@ -280,8 +300,24 @@ def test_project_history_index_is_authorized_bounded_and_versioned() -> None: assert result["contract_version"] == 1 assert result["project_count"] == 1 assert result["projects"][0]["truth_status_code"] == "observed" + assert result["projects"][0]["latest_event_at"] == "2026-01-02T00:00:00Z" + assert result["knowledge_cutoff"] == "2026-08-20T00:00:00Z" assert connection.calls[0][1][0] == ["corp-1"] - assert connection.calls[0][1][-1] == 2 + assert connection.calls[0][1][-2] == 2 + assert "set_config(" in connection.calls[0][0] + assert "'statement_timeout'" in connection.calls[0][0] + assert "limit ($4 + 1)" in connection.calls[0][0] + + connection.rows[0]["source_scan_truncated"] = True + truncated = asyncio.run( + fetch_project_history_index( + connection, + knowledge_cutoff=datetime(2026, 8, 20, tzinfo=timezone.utc), + corporate_entity_ids=["corp-1"], + limit=1, + ) + ) + assert truncated["truncated"] is True with pytest.raises(ValueError): asyncio.run( @@ -324,7 +360,7 @@ def test_project_history_projection_keeps_focus_and_authorization_bounds() -> No assert result["truncated"] is True assert result["focus_event_id"] == "00000000-0000-0000-0000-000000000099" - assert result["knowledge_cutoff"].endswith("+00:00") + assert result["knowledge_cutoff"] == "2026-08-20T00:00:00Z" assert len(connection.calls) == 5 assert connection.calls[0][1][-1] == 3 From ccaeaa13235b1b36797a7f17bf0dd0b754e5e708 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 12:28:03 +0900 Subject: [PATCH 116/118] test: align project history document clock copy --- frontend/src/components/ProjectHistoryTimeline.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/ProjectHistoryTimeline.test.tsx b/frontend/src/components/ProjectHistoryTimeline.test.tsx index 098a295b4..8b53f654b 100644 --- a/frontend/src/components/ProjectHistoryTimeline.test.tsx +++ b/frontend/src/components/ProjectHistoryTimeline.test.tsx @@ -177,6 +177,6 @@ describe("ProjectHistoryTimeline", () => { onOpenPost={vi.fn()} />, ); - expect(screen.getByText("Dates use the recorded document time.")).toBeInTheDocument(); + expect(screen.getByText("Dates use the document time recorded by the source.")).toBeInTheDocument(); }); }); From 4f3601307d5bc8a9415bd6758310ac94426e41b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 19:28:17 +0900 Subject: [PATCH 117/118] fix: preserve project history focus across key normalization --- frontend/src/App.test.tsx | 16 +++++++++++++++- frontend/src/App.tsx | 18 +++++++++++++----- frontend/src/projectHistory.ts | 2 +- tests/test_project_history.py | 2 +- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 26a9a44f8..1c816e68e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -90,6 +90,7 @@ describe("App, authenticated", () => { deferPostOneSummary?: boolean; deferSecondAsk?: boolean; deferProjectHistory?: boolean; + projectHistoryProjectKey?: string; invalidAskSessionOnce?: boolean; meFailed?: boolean; postBody?: string; @@ -1171,7 +1172,7 @@ describe("App, authenticated", () => { visibility_label: "Public", project_evidence: [ { - project_key: "semantic-project", + project_key: options?.projectHistoryProjectKey ?? "semantic-project", project_name: "Semantic project", evidence: "project was described in the body", confidence: 0.9, @@ -2106,6 +2107,19 @@ describe("App, authenticated", () => { expect(projectHistoryRequestUrl).toContain("focus_post_id=post-1"); }); + it("keeps the focus post when the project key differs only by identity normalization", async () => { + stubBackend({ projectHistoryProjectKey: "SEMANTIC-PROJECT" }); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click( + await screen.findByRole("button", { name: "Open project history for: Semantic project" }), + ); + + expect(await screen.findByRole("heading", { name: "Project event timeline" })).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Select project" })).toHaveValue("semantic-project"); + expect(projectHistoryRequestUrl).toContain("focus_post_id=post-1"); + }); + it("shows a next-action loading state while project history is requested", async () => { const fetchMock = stubBackend({ deferProjectHistory: true }); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c2d5f4ee8..032deac1f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -94,6 +94,7 @@ import { decodeHtmlEntities } from "./postBodyDisplay"; import { FiveW1H } from "./components/FiveW1H"; import { ProjectHistoryTimeline } from "./components/ProjectHistoryTimeline"; import { + normalizeProjectIdentity, projectHistoryText, type ProjectHistoryIndex, type ProjectHistoryProjection, @@ -4722,11 +4723,15 @@ function ProjectHistoryPanel({ fetchProjectHistoryIndex(accessToken) .then((result) => { if (!active) return; + const initialProject = initialProjectKey + ? result.projects.find( + (project) => + project.normalized_project_key === normalizeProjectIdentity(initialProjectKey), + ) + : undefined; setIndex(result); - setSelectedProjectKey((current) => - initialProjectKey && result.projects.some((project) => project.project_key === initialProjectKey) - ? initialProjectKey - : current || result.projects[0]?.project_key || "", + setSelectedProjectKey( + (current) => initialProject?.project_key ?? (current || result.projects[0]?.project_key || ""), ); setError(false); }) @@ -4758,7 +4763,10 @@ function ProjectHistoryPanel({ setLoadingHistory(true); setError(false); const focusPostId = - initialProjectKey === selectedProjectKey ? initialFocusPostId ?? undefined : undefined; + initialProjectKey && + normalizeProjectIdentity(initialProjectKey) === normalizeProjectIdentity(selectedProjectKey) + ? initialFocusPostId ?? undefined + : undefined; fetchProjectHistory(accessToken, selectedProjectKey, index.knowledge_cutoff, focusPostId) .then((result) => { if (request !== historyRequest.current) return; diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts index 208202746..d02a9158c 100644 --- a/frontend/src/projectHistory.ts +++ b/frontend/src/projectHistory.ts @@ -101,7 +101,7 @@ export interface ProjectEvidenceGroup { evidence: ProjectEvidence[]; } -function normalizeProjectIdentity(value: string): string { +export function normalizeProjectIdentity(value: string): string { return value.normalize("NFKC").trim().toLocaleLowerCase("en-US"); } diff --git a/tests/test_project_history.py b/tests/test_project_history.py index 752ce1b4b..79e6cb3db 100644 --- a/tests/test_project_history.py +++ b/tests/test_project_history.py @@ -118,7 +118,7 @@ def test_responsibility_transition_describes_document_evidence_only() -> None: assert responsibility_transition_code(["person:a"], []) == "assignment_gap" -def test_assignment_gap_without_role_evidence_has_no_truth_status() -> None: +def test_focused_assignment_gap_without_role_evidence_has_no_truth_status() -> None: """Two empty role sets must not manufacture an observed assignment fact.""" second = _event_row("00000000-0000-0000-0000-000000000002") second["created_at"] = datetime(2022, 3, 12, 9, tzinfo=timezone.utc) From a8f56d0d0ab3705001584e3e33e69b9741a9c2bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:32:11 -0700 Subject: [PATCH 118/118] feat: recover TEPP validation on canonical Project history (v2.19.0) (#339) * test: specify recovered TEPP project-history boundary * test: specify TEPP project-history buyer evidence * feat: recover strict TEPP project-history client * feat: map canonical project history into TEPP contract * feat: render TEPP validation beside canonical history * style: add TEPP project-history evidence panel * docs: add TEPP project-history Storybook states * chore: stage one-shot TEPP project-history recovery * docs: record recovered TEPP project-history boundary * ci: execute verified TEPP project-history recovery * fix(i18n): cover Vietnamese TEPP project-history copy * test(red): require TEPP evidence on canonical timeline * fix(ci): make TEPP recovery red gate integration-specific * fix(ui): harden TEPP evidence semantics and accessibility * test(ui): cover TEPP evidence labels and unique regions * test(red): reject unrecognized TEPP findings and invalid responses * fix(ci): apply complete strict TEPP recovery contract * ci: format and lint TEPP recovery before commit * docs(adr): close TEPP finding vocabulary and response boundary * fix(ci): cancel stale TEPP recovery attempts * feat: materialize TEPP project history recovery * test: keep TEPP evidence type-safe * fix: fail closed on unknown TEPP findings * fix(http): let strict contracts suppress LLM metadata * fix(tepp): keep contextual metadata out of strict payloads * test(tepp): prove strict payloads suppress LLM metadata * fix(tepp): preserve strict validation while suppressing metadata * fix(tepp): normalize unexpected provider failures * fix: enforce TEPP project history byte limits * fix: order TEPP history events by timestamp * fix: sanitize TEPP transport provider errors * docs: record TEPP provider error boundary * feat: connect Ask answers to canonical project histories (v2.20.0) (#342) * test(red): require authorization-safe Ask project histories * test(red): require canonical project histories in Ask answers * chore: stage project-history Ask integration transform * ci: verify project-history Ask integration * fix(ci): repair Ask transform f-string and import anchors * fix(ci): repair generated Ask SQL before verification * test(red): persist the exact post Ask knowledge cutoff * fix(ci): preserve exact Ask cutoffs and stale-request safety * ci: verify exact persisted Ask cutoffs * ci: trigger Ask integration verification on stacked PR * ci: restack Ask integration on latest TEPP recovery * fix(ci): bind persisted Ask clocks to one application timeline * test(ask): keep persisted answer time after its cutoff * ci: verify hardened Ask cutoff persistence * test(db): reserve the next migration after project history * fix(ci): allocate Ask cutoff migration after project history * test(db): use the next available migration sequence * fix(ci): allocate the first free Ask cutoff migration * feat: connect Ask answers to project histories * docs: record Ask integration gate * docs: avoid stale self head checkpoint * fix: contain provider failures across Ask and TEPP * ci: verify PR 342 Global Ask stabilization * ci: publish verified PR 342 stabilization * fix: make global ask cutoff source-of-truth * test: reproduce Global Ask cutoff bind regression * fix: preserve global ask tenant scope * docs: assign unique ADR numbers after stack merge --- .../2.20.0-global-ask-cutoff-safety.md | 5 + CHANGELOG.md | 23 + backend/app/analysis_run_start.py | 44 +- backend/app/ask_project_history.py | 310 ++++++++++++ backend/app/main.py | 125 ++++- backend/app/post_chat_ingestion.py | 83 +++- backend/app/tepp_project_history.py | 208 ++++++++ backend/tests/test_api.py | 6 + docker/postgres-init/migrate.sh | 2 +- ...3-project-history-links-in-ask-surfaces.md | 56 +++ ...lobal-ask-cutoff-and-migration-identity.md | 40 ++ ...validation-on-canonical-project-history.md | 98 ++++ ...9-customer-master-three-pane-workspace.md} | 2 +- docs/product-technical-gap-baseline.md | 37 +- frontend/package.json | 2 +- frontend/src/App.tsx | 51 +- frontend/src/api.ts | 18 + .../src/components/AskProjectHistoryLinks.css | 36 ++ .../AskProjectHistoryLinks.stories.tsx | 44 ++ .../AskProjectHistoryLinks.test.tsx | 123 +++++ .../src/components/AskProjectHistoryLinks.tsx | 177 +++++++ .../ProjectHistoryTimeline.tepp.test.tsx | 67 +++ .../src/components/ProjectHistoryTimeline.tsx | 11 + .../components/TeppProjectHistoryEvidence.css | 80 ++++ .../TeppProjectHistoryEvidence.stories.tsx | 61 +++ .../TeppProjectHistoryEvidence.test.tsx | 113 +++++ .../components/TeppProjectHistoryEvidence.tsx | 238 +++++++++ frontend/src/projectHistory.ts | 40 ++ lineageweave/__init__.py | 2 +- lineageweave/http_client.py | 38 +- lineageweave/tepp_client.py | 10 +- lineageweave/tepp_project_history.py | 450 ++++++++++++++++++ .../0054_post_chat_knowledge_cutoff.sql | 28 ++ .../0054_post_chat_knowledge_cutoff.sql | 5 + pyproject.toml | 2 +- tests/test_ask_project_history.py | 348 ++++++++++++++ tests/test_ask_project_history_cutoff.py | 77 +++ tests/test_global_ask_cutoff_contract.py | 54 +++ tests/test_global_ask_cutoff_postgres.py | 82 ++++ tests/test_migration_identity.py | 30 ++ tests/test_strict_http_metadata.py | 205 ++++++++ tests/test_tepp_client.py | 31 ++ tests/test_tepp_project_history_recovery.py | 408 ++++++++++++++++ uv.lock | 2 +- 44 files changed, 3794 insertions(+), 78 deletions(-) create mode 100644 CHANGELOG.d/2.20.0-global-ask-cutoff-safety.md create mode 100644 backend/app/ask_project_history.py create mode 100644 backend/app/tepp_project_history.py create mode 100644 docs/adr/0113-project-history-links-in-ask-surfaces.md create mode 100644 docs/adr/0125-global-ask-cutoff-and-migration-identity.md create mode 100644 docs/adr/0127-recover-tepp-validation-on-canonical-project-history.md rename docs/adr/{0125-customer-master-three-pane-workspace.md => 0129-customer-master-three-pane-workspace.md} (99%) create mode 100644 frontend/src/components/AskProjectHistoryLinks.css create mode 100644 frontend/src/components/AskProjectHistoryLinks.stories.tsx create mode 100644 frontend/src/components/AskProjectHistoryLinks.test.tsx create mode 100644 frontend/src/components/AskProjectHistoryLinks.tsx create mode 100644 frontend/src/components/ProjectHistoryTimeline.tepp.test.tsx create mode 100644 frontend/src/components/TeppProjectHistoryEvidence.css create mode 100644 frontend/src/components/TeppProjectHistoryEvidence.stories.tsx create mode 100644 frontend/src/components/TeppProjectHistoryEvidence.test.tsx create mode 100644 frontend/src/components/TeppProjectHistoryEvidence.tsx create mode 100644 lineageweave/tepp_project_history.py create mode 100644 migrations/0054_post_chat_knowledge_cutoff.sql create mode 100644 migrations/rollback/0054_post_chat_knowledge_cutoff.sql create mode 100644 tests/test_ask_project_history.py create mode 100644 tests/test_ask_project_history_cutoff.py create mode 100644 tests/test_global_ask_cutoff_contract.py create mode 100644 tests/test_global_ask_cutoff_postgres.py create mode 100644 tests/test_migration_identity.py create mode 100644 tests/test_strict_http_metadata.py create mode 100644 tests/test_tepp_project_history_recovery.py diff --git a/CHANGELOG.d/2.20.0-global-ask-cutoff-safety.md b/CHANGELOG.d/2.20.0-global-ask-cutoff-safety.md new file mode 100644 index 000000000..77d039572 --- /dev/null +++ b/CHANGELOG.d/2.20.0-global-ask-cutoff-safety.md @@ -0,0 +1,5 @@ +### Fixed + +- Bind the Global Ask knowledge cutoff in the final authorized-source query. +- Give the post-chat cutoff migration a unique `0054` identity and remove + self-modifying stabilization workflows. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6086552..bce904fea 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). +## [2.20.0] - 2026-08-21 + +### Added + +- Post-scoped Ask and Global Ask now attach exact project-history links derived + only from currently authorized cited posts. Opening a link reuses the canonical + Project history timeline and its optional TEPP validation at the answer cutoff. + +### Security + +- Persisted post answers are withheld when any citation is no longer visible, and + stale Global Ask sessions are restarted before hidden prior prose can re-enter + conversation context (ADR 0113). + +## [2.19.0] - 2026-08-21 + +### Added + +- Recovered the credential-free TEPP project-history validation boundary on top of + the canonical Buyer timeline. TEPP may return only cutoff-safe temporal + associations over the exact authorized events; the timeline remains readable + when TEPP is absent, and no result is labelled as a cause (ADR 0127). + ## [2.18.0] - 2026-08-20 ### Added diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 324c6cd67..fd5be4912 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -11,7 +11,7 @@ import hashlib import json -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from uuid import UUID @@ -21,13 +21,13 @@ AnalysisRunCreateError, fetch_visible_analysis_run, ) -from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL 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 backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL from lineageweave.adjudication_client import AdjudicationClient from lineageweave.http_client import HttpClientError, post_json from lineageweave.lineage_persistence import lineage_edge_specs @@ -119,12 +119,12 @@ def tepp_run_request( """Build TEPP's published request from the frozen run, never a theta.""" cutoff = knowledge_cutoff if cutoff.tzinfo is None: - cutoff = cutoff.replace(tzinfo=timezone.utc) + cutoff = cutoff.replace(tzinfo=UTC) return AnalysisRunRequest( idempotency_key=idempotency_key, tenant_workspace_id=str(corporate_entity_id), snapshot_id=snapshot_sha256, - knowledge_cutoff=cutoff.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + knowledge_cutoff=cutoff.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), model_contract_version=_TEPP_MODEL_CONTRACT, output_profile=_TEPP_OUTPUT_PROFILE, ) @@ -610,7 +610,7 @@ async def deliver_queued_analysis_run( return await _visible_or_404( conn, analysis_run_id, account_id, affiliated_entity_ids ) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) try: if not latest_outbox_delivery_is_claimed(latest): await _append_outbox_delivery( @@ -636,9 +636,8 @@ async def deliver_queued_analysis_run( affiliated_entity_ids=affiliated_entity_ids, adjudication_client=adjudication_client, ) - finished = datetime.now(timezone.utc) - if finished < now: - finished = now + finished = datetime.now(UTC) + finished = max(finished, now) await _append_outbox_delivery( conn, analysis_run_id, @@ -699,7 +698,7 @@ async def _deliver_lineage_reconstruction( adjudication_client: AdjudicationClient | None = None, ) -> None: """Persist ThreadWeave parent choices for the frozen bag.""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) member_rows = await _snapshot_member_posts( conn, locked["analysis_source_snapshot_id"], @@ -715,9 +714,8 @@ async def _deliver_lineage_reconstruction( ) edges = lineage_edge_specs(records_from_source_posts(rows), llm=adjudication_client) digest = reconstruction_result_digest(edges) - finished = datetime.now(timezone.utc) - if finished < now: - finished = now + finished = datetime.now(UTC) + finished = max(finished, now) await conn.execute( """ insert into analysis_run_reconstruction @@ -759,7 +757,7 @@ async def _deliver_tepp_measurement( tepp_client: TeppClient, ) -> None: """Submit the frozen snapshot through ``tepp_client``. Never persist a theta.""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) request = tepp_run_request( idempotency_key=str(locked["idempotency_key"]), snapshot_sha256=str(locked["snapshot_sha256"]), @@ -767,17 +765,15 @@ async def _deliver_tepp_measurement( corporate_entity_id=str(locked["corporate_entity_id"]), ) status_code, failure_code, envelope = _tepp_submission(tepp_client, request) - if status_code == _SUCCEEDED and envelope is not None: - if not await _persist_tepp_result( - conn, - analysis_run_id=analysis_run_id, - envelope=envelope, - ): - status_code = _FAILED - failure_code = "tepp_result_not_persisted" - finished = datetime.now(timezone.utc) - if finished < now: - finished = now + if status_code == _SUCCEEDED and envelope is not None and not await _persist_tepp_result( + conn, + analysis_run_id=analysis_run_id, + envelope=envelope, + ): + status_code = _FAILED + failure_code = "tepp_result_not_persisted" + finished = datetime.now(UTC) + finished = max(finished, now) await _append_status( conn, analysis_run_id, diff --git a/backend/app/ask_project_history.py b/backend/app/ask_project_history.py new file mode 100644 index 000000000..1bf97a57e --- /dev/null +++ b/backend/app/ask_project_history.py @@ -0,0 +1,310 @@ +"""Authorization-safe project-history links for Ask responses. + +The module accepts only citation identities already produced by post-scoped or +Global Ask. It re-applies current tenant visibility, source publication +eligibility, and the answer knowledge cutoff before returning citation labels or +project identities. A missing citation fails the whole persisted answer closed; +answer prose cannot be safely decomposed after one of its sources becomes +unauthorized. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any, Protocol +from uuid import UUID + +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from lineageweave.project_history import normalize_project_key + +ASK_CITATION_LIMIT = 64 +ASK_PROJECT_LIMIT = 8 +GLOBAL_ASK_SESSION_CITATION_LIMIT = 256 + +_ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias="post") +_CITATION_PROJECT_SQL = f""" +with visible_citation as materialized ( + select post.post_id::text as post_id, + post.post_title, + array_position($1::uuid[], post.post_id) as citation_ordinal, + nullif(btrim(post.source_project_code), '') as source_project_code, + nullif(btrim(post.source_project_name), '') as source_project_name + from source_post post + where post.post_id = any($1::uuid[]) + and (post.visibility_code = 'public' + or post.corporate_entity_id::text = any($2::text[])) + and post.created_at <= $3 + and {_ELIGIBILITY} +), project_evidence as ( + select visible_citation.post_id, + coalesce(visible_citation.source_project_code, + visible_citation.source_project_name) as project_key, + coalesce(visible_citation.source_project_name, + visible_citation.source_project_code) as project_name, + 'observed'::text as truth_status_code, + 0::integer as truth_order + from visible_citation + where coalesce(visible_citation.source_project_code, + visible_citation.source_project_name) is not null + union all + select visible_citation.post_id, + coalesce(nullif(btrim(mention.project_key), ''), + nullif(btrim(mention.project_name), '')) as project_key, + coalesce(nullif(btrim(mention.project_name), ''), + nullif(btrim(mention.project_key), '')) as project_name, + 'inferred'::text as truth_status_code, + 1::integer as truth_order + from visible_citation + join post_project_mention mention + on mention.post_id::text = visible_citation.post_id + where coalesce(nullif(btrim(mention.project_key), ''), + nullif(btrim(mention.project_name), '')) is not null +) +select visible_citation.post_id, + visible_citation.post_title, + visible_citation.citation_ordinal, + project_evidence.project_key, + project_evidence.project_name, + project_evidence.truth_status_code, + project_evidence.truth_order + from visible_citation + left join project_evidence + on project_evidence.post_id = visible_citation.post_id + order by visible_citation.citation_ordinal, + project_evidence.truth_order nulls last, + project_evidence.project_name nulls last, + project_evidence.project_key nulls last +""" +_SESSION_CITATION_SQL = """ +select distinct cited_post_id::text as cited_post_id + from global_ask_turn_citation + where global_ask_session_id = $1 + order by cited_post_id::text + limit $2 +""" + + +class AskEvidenceConnection(Protocol): + """Minimal async query port used by this read projection.""" + + async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]: + """Execute a bounded read query.""" + + raise NotImplementedError + + +@dataclass(frozen=True) +class AskEvidenceProjection: + """Currently authorized citation labels and exact project links.""" + + all_citations_visible: bool + cited_posts: tuple[dict[str, str], ...] + project_histories: tuple[dict[str, Any], ...] + project_histories_truncated: bool + knowledge_cutoff: str + + def response_fields(self) -> dict[str, Any]: + """Return the public response fields shared by both Ask surfaces.""" + + return { + "cited_posts": list(self.cited_posts), + "project_histories": list(self.project_histories), + "project_histories_truncated": self.project_histories_truncated, + "knowledge_cutoff": self.knowledge_cutoff, + } + + +def ask_knowledge_cutoff(value: object | None = None) -> datetime: + """Return an offset-aware UTC cutoff from a datetime or ISO text.""" + + if value is None: + return datetime.now(UTC) + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + normalized = value.strip() + if normalized.endswith("Z"): + normalized = f"{normalized[:-1]}+00:00" + parsed = datetime.fromisoformat(normalized) + except ValueError as exc: + raise ValueError("knowledge cutoff must be ISO-8601") from exc + else: + raise ValueError("knowledge cutoff must be a datetime or ISO-8601 text") + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError("knowledge cutoff must include an offset") + return parsed.astimezone(UTC) + + +def _cutoff_text(value: datetime) -> str: + """Serialize one validated cutoff as canonical UTC RFC 3339 text.""" + + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def _bounded_citations( + cited_post_ids: Iterable[str], *, maximum_citations: int +) -> tuple[str, ...]: + """Return unique citation IDs without silently truncating evidence.""" + + try: + citations = tuple( + dict.fromkeys( + str(UUID(str(value))) for value in cited_post_ids if str(value).strip() + ) + ) + except (AttributeError, TypeError, ValueError) as exc: + raise ValueError("citation identities must be UUIDs") from exc + if len(citations) > maximum_citations: + raise ValueError("citation count exceeds the supported bound") + return citations + + +async def read_authorized_ask_evidence( + conn: AskEvidenceConnection, + *, + cited_post_ids: Iterable[str], + corporate_entity_ids: Iterable[str], + knowledge_cutoff: datetime | str, + maximum_citations: int = ASK_CITATION_LIMIT, + maximum_projects: int = ASK_PROJECT_LIMIT, +) -> AskEvidenceProjection: + """Reauthorize citations and derive bounded exact-project history links. + + A citation is visible only when its current source row passes tenant ABAC, + publication eligibility, and the answer cutoff. If any citation is absent, + project links are withheld and callers must not reuse the persisted answer. + """ + + cutoff = ask_knowledge_cutoff(knowledge_cutoff) + cutoff_text = _cutoff_text(cutoff) + citations = _bounded_citations( + cited_post_ids, + maximum_citations=maximum_citations, + ) + if not citations: + return AskEvidenceProjection(True, (), (), False, cutoff_text) + rows = list( + await conn.fetch( + _CITATION_PROJECT_SQL, + list(citations), + list(corporate_entity_ids), + cutoff, + ) + ) + citation_order = {post_id: index for index, post_id in enumerate(citations, start=1)} + visible_titles: dict[str, str] = {} + for row in rows: + post_id = str(row["post_id"]) + if post_id in citation_order: + visible_titles.setdefault(post_id, str(row["post_title"])) + all_visible = set(visible_titles) == set(citations) + cited_posts = tuple( + {"post_id": post_id, "post_title": visible_titles[post_id]} + for post_id in citations + if post_id in visible_titles + ) + if not all_visible: + return AskEvidenceProjection(False, cited_posts, (), False, cutoff_text) + + evidence_rows = sorted( + ( + row + for row in rows + if row.get("project_key") is not None and row.get("project_name") is not None + ), + key=lambda row: ( + citation_order[str(row["post_id"])], + int(row.get("truth_order") or 0), + str(row["project_name"]), + str(row["project_key"]), + ), + ) + grouped: dict[str, dict[str, Any]] = {} + for row in evidence_rows: + project_key = str(row["project_key"]).strip() + project_name = str(row["project_name"]).strip() + try: + normalized_key = normalize_project_key(project_key) + except ValueError: + continue + post_id = str(row["post_id"]) + truth_order = int(row.get("truth_order") or 0) + group = grouped.get(normalized_key) + if group is None: + grouped[normalized_key] = { + "project_key": project_key, + "project_name": project_name, + "focus_post_id": post_id, + "source_post_ids": [post_id], + "knowledge_cutoff": cutoff_text, + "truth_status_code": str(row["truth_status_code"]), + "truth_order": truth_order, + "first_citation_ordinal": citation_order[post_id], + } + continue + if post_id not in group["source_post_ids"]: + group["source_post_ids"].append(post_id) + if truth_order < group["truth_order"]: + group["project_key"] = project_key + group["project_name"] = project_name + group["truth_status_code"] = str(row["truth_status_code"]) + group["truth_order"] = truth_order + + ordered = sorted( + grouped.values(), + key=lambda group: ( + int(group["first_citation_ordinal"]), + str(group["project_name"]), + str(group["project_key"]), + ), + ) + truncated = len(ordered) > maximum_projects + public_links: list[dict[str, Any]] = [] + for group in ordered[:maximum_projects]: + public_links.append( + { + key: value + for key, value in group.items() + if key not in {"truth_order", "first_citation_ordinal"} + } + ) + return AskEvidenceProjection( + True, + cited_posts, + tuple(public_links), + truncated, + cutoff_text, + ) + + +async def global_ask_session_citations_authorized( + conn: AskEvidenceConnection, + *, + session_id: str, + corporate_entity_ids: Iterable[str], + knowledge_cutoff: datetime | str, +) -> bool: + """Return whether every citation ever reused by a session is still visible.""" + + rows = list( + await conn.fetch( + _SESSION_CITATION_SQL, + session_id, + GLOBAL_ASK_SESSION_CITATION_LIMIT + 1, + ) + ) + if len(rows) > GLOBAL_ASK_SESSION_CITATION_LIMIT: + return False + citations = [str(row["cited_post_id"]) for row in rows] + result = await read_authorized_ask_evidence( + conn, + cited_post_ids=citations, + corporate_entity_ids=corporate_entity_ids, + knowledge_cutoff=knowledge_cutoff, + maximum_citations=GLOBAL_ASK_SESSION_CITATION_LIMIT, + maximum_projects=0, + ) + return result.all_citations_visible diff --git a/backend/app/main.py b/backend/app/main.py index 7f699da16..06f6d2457 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -186,6 +186,11 @@ persist_post_summary, require_summary_source_body, ) +from backend.app.ask_project_history import ( + ask_knowledge_cutoff, + global_ask_session_citations_authorized, + read_authorized_ask_evidence, +) from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL from backend.app.project_history import ( PROJECT_HISTORY_DEFAULT_LIMIT, @@ -196,6 +201,10 @@ fetch_project_history_index, fetch_project_history_projection, ) +from backend.app.tepp_project_history import ( + tenant_workspace_reference, + validate_project_history_with_tepp, +) from lineageweave.project_history import normalize_project_key from backend.app.demo_scope import ( fetch_demo_corporate_entity_ids, @@ -2683,9 +2692,25 @@ async def read_post_chat( an empty list, not a fabricated transcript. """ await _load_visible_post(post_id, account, pool) + authorized_exchanges: list[dict[str, Any]] = [] async with pool.acquire() as conn: exchanges = await fetch_persisted_chats(conn, post_id) - return {"post_id": post_id, "exchanges": exchanges} + for exchange in exchanges: + cutoff = ask_knowledge_cutoff(exchange.get("_knowledge_cutoff")) + evidence = await read_authorized_ask_evidence( + conn, + cited_post_ids=exchange["cited_post_ids"], + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=cutoff, + ) + if not evidence.all_citations_visible: + continue + public_exchange = { + key: value for key, value in exchange.items() if not key.startswith("_") + } + public_exchange.update(evidence.response_fields()) + authorized_exchanges.append(public_exchange) + return {"post_id": post_id, "exchanges": authorized_exchanges} @app.post("/api/posts/{post_id}/chat") @@ -2711,18 +2736,28 @@ async def chat_about_post( raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "question is required") post = await _load_visible_post(post_id, account, pool) post_metadata = build_post_llm_metadata(post_id, post) + knowledge_cutoff = ask_knowledge_cutoff() async with pool.acquire() as conn: stored = await fetch_persisted_chat(conn, post_id, question) if stored is not None: - source_ids = [post_id] - source_ids.extend(cid for cid in stored["cited_post_ids"] if cid != post_id) - return { - "post_id": post_id, - "answer_text": stored["answer_text"], - "cited_post_ids": stored["cited_post_ids"], - "cited_posts": stored["cited_posts"], - "source_post_ids": source_ids, - } + stored_cutoff = ask_knowledge_cutoff(stored.get("_knowledge_cutoff")) + stored_evidence = await read_authorized_ask_evidence( + conn, + cited_post_ids=stored["cited_post_ids"], + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=stored_cutoff, + ) + if stored_evidence.all_citations_visible: + source_ids = list( + dict.fromkeys([post_id, *stored["cited_post_ids"]]) + ) + return { + "post_id": post_id, + "answer_text": stored["answer_text"], + "cited_post_ids": stored["cited_post_ids"], + "source_post_ids": source_ids, + **stored_evidence.response_fields(), + } with use_llm_metadata(post_metadata): client = _post_chat_client() if not client.available: @@ -2731,7 +2766,11 @@ async def chat_about_post( "Post chat is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", ) sources = await gather_chat_sources( - conn, post_id, lambda row: _can_see_post(account, row), vision_client=_vision_client() + conn, + post_id, + lambda row: _can_see_post(account, row), + vision_client=_vision_client(), + knowledge_cutoff=knowledge_cutoff, ) try: with use_llm_metadata(post_metadata): @@ -2748,7 +2787,25 @@ async def chat_about_post( ) from exc cited_ids = list(answer.cited_post_ids) async with pool.acquire() as conn: - await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) + await persist_post_chat( + conn, + post_id, + question, + answer.answer_text, + cited_ids, + knowledge_cutoff=knowledge_cutoff, + ) + answer_evidence = await read_authorized_ask_evidence( + conn, + cited_post_ids=cited_ids, + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=knowledge_cutoff, + ) + if not answer_evidence.all_citations_visible: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post chat evidence changed before the answer could be returned", + ) await publish_activity_event( valkey, post_id, @@ -2760,8 +2817,8 @@ async def chat_about_post( "post_id": post_id, "answer_text": answer.answer_text, "cited_post_ids": cited_ids, - "cited_posts": cited_post_summaries(sources, cited_ids), "source_post_ids": [source.post_id for source in sources], + **answer_evidence.response_fields(), } @@ -2782,6 +2839,7 @@ async def ask_agent( UUID(request.session_id) except ValueError: raise HTTPException(status.HTTP_404_NOT_FOUND, "Global Ask session not found") from None + knowledge_cutoff = ask_knowledge_cutoff() client = _post_chat_client() if not client.available: raise HTTPException( @@ -2794,12 +2852,23 @@ async def ask_agent( ) if session_id is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Global Ask session not found") + if not await global_ask_session_citations_authorized( + conn, + session_id=session_id, + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=knowledge_cutoff, + ): + raise HTTPException( + status.HTTP_409_CONFLICT, + "Global Ask session evidence is no longer authorized; start a new session", + ) conversation = await load_global_ask_context(conn, session_id) sources = await gather_global_chat_sources( conn, lambda row: _can_see_post(account, row), account.corporate_entity_ids, question=question, + knowledge_cutoff=knowledge_cutoff, ) if conversation.compress_turns: compressor = getattr(client, "compress_context", None) @@ -2827,6 +2896,11 @@ async def ask_agent( status.HTTP_503_SERVICE_UNAVAILABLE, "Ask Agent conversation context compression is unavailable", ) from exc + except Exception as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Ask Agent conversation context compression is unavailable", + ) from exc conversation_context = render_global_ask_context( conversation.summary, conversation.recent_turns, @@ -2848,6 +2922,9 @@ async def ask_agent( "source_post_ids": [], "cited_post_evidence": [], "timeline": [], + "project_histories": [], + "project_histories_truncated": False, + "knowledge_cutoff": knowledge_cutoff.isoformat().replace("+00:00", "Z"), "next_action": "No authorized source posts are available for this question.", } try: @@ -2876,6 +2953,17 @@ async def ask_agent( answer.answer_text, cited_ids, ) + answer_evidence = await read_authorized_ask_evidence( + conn, + cited_post_ids=cited_ids, + corporate_entity_ids=account.corporate_entity_ids, + knowledge_cutoff=knowledge_cutoff, + ) + if not answer_evidence.all_citations_visible: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Global Ask evidence changed before the answer could be returned", + ) await publish_operation_event( valkey, account.user_account_id, @@ -2886,10 +2974,10 @@ async def ask_agent( "session_id": conversation.session_id, "answer_text": answer.answer_text, "cited_post_ids": cited_ids, - "cited_posts": cited_post_summaries(sources, cited_ids), "cited_post_evidence": cited_post_evidence(sources, cited_ids), "source_post_ids": [source.post_id for source in sources], "timeline": global_ask_timeline(sources), + **answer_evidence.response_fields(), } @@ -3439,7 +3527,7 @@ async def read_project_history( ) from exc async with pool.acquire() as conn: try: - return await fetch_project_history_projection( + projection = await fetch_project_history_projection( conn, project_key=project_key, focus_post_id=focus_post_id, @@ -3449,6 +3537,13 @@ async def read_project_history( ) except ProjectHistoryNotFound as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, "project history not found") from exc + projection["tepp_validation"] = await asyncio.to_thread( + validate_project_history_with_tepp, + projection=projection, + tenant_workspace_id=tenant_workspace_reference(account.corporate_entity_ids), + transport_url=load_settings().tepp_transport_url, + ) + return projection @app.get("/api/rankings") diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index e60b00aa5..8daf01b1f 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -20,6 +20,7 @@ import asyncio import re from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any, Callable, Iterable from uuid import uuid4 @@ -44,6 +45,7 @@ from lineageweave.post_content_normalization import normalize_post_body from .knowledge_graph import hydrate_related_nodes, load_visible_subgraph +from .post_eligibility import SOURCE_POST_ELIGIBILITY_SQL from lineageweave.ontology import ontology_annotations @@ -340,6 +342,16 @@ async def _graph_facts_for_posts( _GLOBAL_ASK_TERM_PATTERN = re.compile(r"[^\W_]+(?:-[^\W_]+)*", re.UNICODE) _POST_CHAT_SOURCE_LIMIT = 8 _POST_CHAT_CANDIDATE_LIMIT = 32 +_SOURCE_ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias="source_post") + + +def _ask_cutoff(value: datetime | None) -> datetime: + """Return an aware UTC cutoff for one Ask retrieval.""" + + cutoff = value or datetime.now(timezone.utc) + if cutoff.tzinfo is None or cutoff.utcoffset() is None: + raise ValueError("knowledge_cutoff must include an offset") + return cutoff.astimezone(timezone.utc) def _source_hint_facts(row: Any) -> tuple[str, ...]: @@ -453,6 +465,8 @@ async def gather_chat_sources( post_id: str, can_see_post: Callable[[asyncpg.Record], bool], vision_client: ImageContentClient | None = None, + *, + knowledge_cutoff: datetime | None = None, ) -> list[ChatSourceDocument]: """Post `post_id` plus a bounded, deterministic linked-source window. @@ -469,15 +483,18 @@ async def gather_chat_sources( """ if vision_client is None: vision_client = NullImageContentClient() + cutoff = _ask_cutoff(knowledge_cutoff) this_post = await conn.fetchrow( - "select post_id, post_title, post_body, source_system_code, source_record_key, " + "select post_id, post_title, post_body, created_at, source_system_code, source_record_key, " "source_author_code, source_author_name, source_company_code, source_company_name, " "source_process_unit_code, source_process_unit_name, " "source_sales_pool_code, source_sales_pool_name, " "source_customer_code, source_customer_name, source_project_code, " - "source_project_name from source_post where post_id = $1", + f"source_project_name from source_post where post_id = $1 " + f"and created_at <= $2 and {_SOURCE_ELIGIBILITY}", post_id, + cutoff, ) if this_post is None: return [] @@ -510,11 +527,13 @@ async def gather_chat_sources( "source_company_code, source_company_name, source_process_unit_code, " "source_process_unit_name, source_sales_pool_code, source_sales_pool_name, " "source_customer_code, source_customer_name, " - "source_project_code, source_project_name " - "from source_post where post_id = any($1::uuid[]) " + "source_project_code, source_project_name, created_at " + f"from source_post where post_id = any($1::uuid[]) " + f"and created_at <= $3 and {_SOURCE_ELIGIBILITY} " "order by array_position($1::uuid[], post_id) limit $2", candidate_ids, _POST_CHAT_CANDIDATE_LIMIT, + cutoff, ) visible_source_ids = [post_id] visible_rows: list[asyncpg.Record] = [] @@ -558,6 +577,7 @@ async def gather_global_chat_sources( *, question: str | None = None, limit: int = 4, + knowledge_cutoff: datetime | None = None, ) -> list[ChatSourceDocument]: """Assemble a bounded, ABAC-filtered source set for Global Ask. @@ -569,6 +589,8 @@ async def gather_global_chat_sources( return [] if vision_client is None: vision_client = NullImageContentClient() + cutoff = _ask_cutoff(knowledge_cutoff) + authorized_entity_ids = list(authorized_corporate_entity_ids) search_terms = tuple( dict.fromkeys( token.casefold() @@ -612,29 +634,45 @@ async def gather_global_chat_sources( candidate_scores: dict[str, float] = {} for term in search_terms: candidate_rows = await conn.fetch( - """ + f""" select post_id, matched_in from ( (select post_id, created_at, 'title' as matched_in from source_post - where post_title ilike '%' || $1 || '%' + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and created_at <= $3 + and {_SOURCE_ELIGIBILITY} + and post_title ilike '%' || $1 || '%' limit 32) union all (select post_id, created_at, 'body' as matched_in from source_post - where lower(left(source_post_search_text(post_body), 16384)) + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and created_at <= $3 + and {_SOURCE_ELIGIBILITY} + and lower(left(source_post_search_text(post_body), 16384)) like '%' || lower($1) || '%' limit 32) union all (select post_id, created_at, 'body' as matched_in from source_post - where to_tsvector('simple', source_post_search_text(post_body)) + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and created_at <= $3 + and {_SOURCE_ELIGIBILITY} + and to_tsvector('simple', source_post_search_text(post_body)) @@ plainto_tsquery('simple', $1) limit 32) union all (select post_id, created_at, 'source_field' as matched_in from source_post - where concat_ws(' ', source_system_code, source_record_key, + where (visibility_code = 'public' + or corporate_entity_id::text = any($2::text[])) + and created_at <= $3 + and {_SOURCE_ELIGIBILITY} + and concat_ws(' ', source_system_code, source_record_key, source_author_code, source_author_name, source_company_code, source_company_name, source_process_unit_code, source_process_unit_name, @@ -648,6 +686,8 @@ async def gather_global_chat_sources( limit 32 """, term, + authorized_entity_ids, + cutoff, ) for row in candidate_rows: post_id = str(row["post_id"]) @@ -686,7 +726,7 @@ async def gather_global_chat_sources( lineage_neighbor_id_set = frozenset(lineage_neighbor_ids) rows = await conn.fetch( - """ + f""" select post_id, post_title, post_body, visibility_code, corporate_entity_id, created_at, source_system_code, source_record_key, source_author_code, source_author_name, @@ -695,15 +735,18 @@ async def gather_global_chat_sources( source_customer_code, source_customer_name, source_project_code, source_project_name from source_post - where visibility_code = 'public' - or corporate_entity_id::text = any($1::text[]) + where (visibility_code = 'public' + or corporate_entity_id::text = any($1::text[])) + and created_at <= $4 + and {_SOURCE_ELIGIBILITY} order by array_position($2::uuid[], post_id) nulls last, created_at desc, post_id desc limit $3 """, - list(authorized_corporate_entity_ids), + authorized_entity_ids, candidate_ids, limit, + cutoff, ) visible_rows = [row for row in rows if can_see_post(row)][:limit] visible_ids = [str(row["post_id"]) for row in visible_rows] @@ -759,7 +802,7 @@ async def _serialize_chat( ) -> dict[str, Any] | None: """One stored exchange plus citation chips, or None when missing.""" header = await conn.fetchrow( - "select question_text, answer_text from post_chat_result " + "select question_text, answer_text, knowledge_cutoff from post_chat_result " "where post_id = $1 and question_norm = $2", post_id, question_norm, @@ -779,6 +822,7 @@ async def _serialize_chat( "question_text": header["question_text"], "answer_text": header["answer_text"], "cited_post_ids": cited_ids, + "_knowledge_cutoff": header.get("knowledge_cutoff"), "cited_posts": [ {"post_id": str(row["cited_post_id"]), "post_title": row["post_title"]} for row in cites @@ -816,23 +860,30 @@ async def persist_post_chat( question: str, answer_text: str, cited_post_ids: list[str] | tuple[str, ...], + *, + knowledge_cutoff: datetime | None = None, ) -> dict[str, Any]: """Replace the stored exchange for ``(post_id, question)`` and return it.""" norm = normalize_chat_question(question) if not norm: raise ValueError("question is empty after normalize") + cutoff = _ask_cutoff(knowledge_cutoff) + computed_at = max(datetime.now(timezone.utc), cutoff) await conn.execute( "delete from post_chat_result where post_id = $1 and question_norm = $2", post_id, norm, ) await conn.execute( - "insert into post_chat_result (post_id, question_norm, question_text, answer_text) " - "values ($1, $2, $3, $4)", + "insert into post_chat_result " + "(post_id, question_norm, question_text, answer_text, computed_at, knowledge_cutoff) " + "values ($1, $2, $3, $4, $5, $6)", post_id, norm, question.strip(), answer_text, + computed_at, + cutoff, ) seen: set[str] = set() ordinal = 0 diff --git a/backend/app/tepp_project_history.py b/backend/app/tepp_project_history.py new file mode 100644 index 000000000..e7951b520 --- /dev/null +++ b/backend/app/tepp_project_history.py @@ -0,0 +1,208 @@ +"""Map the canonical Buyer project history into TEPP's strict wire contract.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Iterable, Mapping, Sequence +from typing import Any + +from lineageweave.tepp_project_history import ( + PROJECT_HISTORY_CONTRACT_VERSION, + TeppProjectHistoryClient, + TeppProjectHistoryInvalidResponse, + TeppProjectHistoryUnavailable, + parse_rfc3339_utc, + project_history_event_sort_key, + validate_tepp_project_history_request, +) + + +def tenant_workspace_reference(corporate_entity_ids: Iterable[str]) -> str: + """Return a deterministic opaque workspace reference for the ABAC scope.""" + + normalized = sorted({str(value).strip() for value in corporate_entity_ids if str(value).strip()}) + material = "\u001f".join(normalized) if normalized else "public-only" + digest = hashlib.sha256(material.encode("utf-8")).hexdigest() + return f"lw-workspace-{digest}" + + +def _utc_text(value: object, field_name: str) -> str: + """Return canonical UTC text from one offset-aware source timestamp.""" + + return parse_rfc3339_utc(value, field_name)[1] + + +def _opaque_actor_ids( + event: Mapping[str, Any], + *, + tenant_workspace_id: str, +) -> list[str]: + """Hash canonical actor keys so names and local identifiers do not cross.""" + + raw_roles = event.get("responsibility_evidence") + if raw_roles is None: + raw_roles = event.get("observed_responsibilities") + if not isinstance(raw_roles, Sequence) or isinstance(raw_roles, (str, bytes)): + raw_roles = () + actor_ids: set[str] = set() + for role in raw_roles: + if not isinstance(role, Mapping): + continue + actor_key = str(role.get("actor_key") or "").strip() + if not actor_key: + continue + material = f"{tenant_workspace_id}\u0000{actor_key}".encode("utf-8") + actor_ids.add(f"lw-actor-{hashlib.sha256(material).hexdigest()}") + return sorted(actor_ids) + + +def _evidence_text(event: Mapping[str, Any]) -> str: + """Build bounded source-field evidence without sending a post body.""" + + title = str(event.get("event_title") or "").strip() + event_type = str(event.get("event_type_code") or "").strip() + if not title or not event_type: + raise TeppProjectHistoryUnavailable("canonical event title and type are required") + parts = [title, f"event_type={event_type}"] + for key in ("source_stage_code", "source_detail_state_code", "voc_type_code"): + value = str(event.get(key) or "").strip() + if value: + parts.append(f"{key}={value}") + rendered = " | ".join(parts) + encoded = rendered.encode("utf-8") + if len(encoded) <= 4096: + return rendered + return encoded[:4096].decode("utf-8", errors="ignore").rstrip() + + +def _idempotency_key(request_without_key: Mapping[str, Any]) -> str: + """Hash the exact authorized evidence bundle into a stable request key.""" + + material = json.dumps( + request_without_key, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + digest = hashlib.sha256(material.encode("utf-8")).hexdigest() + return f"lineageweave-project-history-{digest}" + + +def build_tepp_project_history_request( + *, + projection: Mapping[str, Any], + tenant_workspace_id: str, +) -> dict[str, Any]: + """Build TEPP #159 input from the already-authorized canonical timeline.""" + + if projection.get("contract_version") != 1: + raise TeppProjectHistoryUnavailable("unsupported canonical project-history version") + events_value = projection.get("events") + if not isinstance(events_value, Sequence) or isinstance(events_value, (str, bytes)): + raise TeppProjectHistoryUnavailable("canonical project history has no event list") + cutoff = _utc_text(projection.get("knowledge_cutoff"), "knowledge_cutoff") + events: list[dict[str, Any]] = [] + for value in events_value: + if not isinstance(value, Mapping): + raise TeppProjectHistoryUnavailable("canonical project event must be an object") + occurred_at = _utc_text(value.get("occurred_at"), "occurred_at") + event_id = str(value.get("event_id") or "").strip() + source_post_id = str(value.get("source_post_id") or "").strip() + if not event_id or not source_post_id: + raise TeppProjectHistoryUnavailable("canonical project event identity is missing") + events.append( + { + "event_id": event_id, + "event_type_code": str(value.get("event_type_code") or "").strip(), + "event_title": str(value.get("event_title") or "").strip(), + "occurred_at": occurred_at, + # The canonical timeline explicitly declares source-post creation + # time as its fallback clock. It is therefore also the earliest + # evidence-availability instant LineageWeave can substantiate. + "available_at": occurred_at, + "source_post_id": source_post_id, + "evidence_text": _evidence_text(value), + "actor_ids": _opaque_actor_ids( + value, + tenant_workspace_id=tenant_workspace_id, + ), + } + ) + events.sort(key=project_history_event_sort_key) + request: dict[str, Any] = { + "contract_version": PROJECT_HISTORY_CONTRACT_VERSION, + "tenant_workspace_id": tenant_workspace_id, + "project_key": str(projection.get("project_key") or "").strip(), + "project_name": str(projection.get("project_name") or "").strip(), + "knowledge_cutoff": cutoff, + "focus_event_id": str(projection.get("focus_event_id") or "").strip(), + "events": events, + } + request["idempotency_key"] = _idempotency_key(request) + return validate_tepp_project_history_request(request) + + +def _buyer_metadata(projection: Mapping[str, Any]) -> dict[str, Any]: + """Strip duplicate event rows while preserving TEPP findings and evidence IDs.""" + + events = projection["events"] + return { + "contract_version": projection["contract_version"], + "project_key": projection["project_key"], + "project_name": projection["project_name"], + "focus_event_id": projection["focus_event_id"], + "knowledge_cutoff": projection["knowledge_cutoff"], + "history_span_start": projection["history_span_start"], + "history_span_end": projection["history_span_end"], + "participant_count": projection["participant_count"], + "inference_status": projection["inference_status"], + "event_count": len(events), + "findings": projection["findings"], + } + + +def validate_project_history_with_tepp( + *, + projection: Mapping[str, Any], + tenant_workspace_id: str, + transport_url: str, +) -> dict[str, Any]: + """Return optional TEPP metadata without hiding the canonical timeline.""" + + if not transport_url.strip(): + return { + "status": "not_configured", + "project_history": None, + "next_action_code": "configure_tepp_project_history", + } + try: + request = build_tepp_project_history_request( + projection=projection, + tenant_workspace_id=tenant_workspace_id, + ) + except TeppProjectHistoryUnavailable: + return { + "status": "invalid_evidence", + "project_history": None, + "next_action_code": "open_source_evidence", + } + try: + validated = TeppProjectHistoryClient(transport_url).project(request) + except TeppProjectHistoryInvalidResponse: + return { + "status": "invalid_evidence", + "project_history": None, + "next_action_code": "open_source_evidence", + } + except TeppProjectHistoryUnavailable: + return { + "status": "unavailable", + "project_history": None, + "next_action_code": "retry_tepp_project_history", + } + return { + "status": "validated", + "project_history": _buyer_metadata(validated), + "next_action_code": "open_source_evidence", + } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 5cc185750..12d60ca98 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -110,6 +110,11 @@ / "migrations" / "0052_global_ask_context.sql" ) +_POST_CHAT_CUTOFF_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0054_post_chat_knowledge_cutoff.sql" +) _MAJOR_EVENT_ACTION_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0100_major_event_action.sql" ) @@ -235,6 +240,7 @@ def seeded_db(demo_analyst_token): cur.execute(_POST_CONTENT_QUEUE_MIGRATION.read_text()) cur.execute(_ORGANIZATION_CONTEXT_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_CONTEXT_MIGRATION.read_text()) + cur.execute(_POST_CHAT_CUTOFF_MIGRATION.read_text()) cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index f442f628a..712107b27 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0051_*|0052_*|0053_*) ;; + 0051_*|0052_*|0053_*|0054_*) ;; 0060_*|0100_*|0101_*|0102_*) ;; *) continue ;; esac diff --git a/docs/adr/0113-project-history-links-in-ask-surfaces.md b/docs/adr/0113-project-history-links-in-ask-surfaces.md new file mode 100644 index 000000000..de521c1c0 --- /dev/null +++ b/docs/adr/0113-project-history-links-in-ask-surfaces.md @@ -0,0 +1,56 @@ +# ADR 0113: Reuse canonical project history in Ask surfaces + +- Status: Proposed +- Date: 2026-08-21 +- Depends on: ADR 0112, ADR 0127, and the canonical Project history read model + +## Context + +Post-scoped Ask and Global Ask already cite authorized source posts, but they did not +connect those citations to the project lifecycle timeline shown in the product design. +The earlier orphaned stack attempted to solve this with another project-history flow. +That would create competing project identity, authorization, cutoff, classification, and +TEPP behavior. + +Persisted Ask prose introduces an additional security boundary: if a previously cited +post becomes hidden, deleted, draft, or otherwise ineligible, returning the old answer or +reusing it as conversation context can disclose facts no longer authorized. + +## Decision + +1. Ask responses expose structured project-history links derived only from cited post IDs. +2. Citation IDs are reauthorized with tenant ABAC, source publication eligibility, and the + answer knowledge cutoff before titles or project identities are returned. +3. Exact source project fields outrank semantic project candidates; inferred identities + remain labelled inferred. Links are bounded and deterministic. +4. Opening a link calls the canonical Project history endpoint with project key, answer + cutoff, and cited focus post. The established timeline and TEPP metadata are reused. +5. A persisted post answer is withheld in full when any citation is no longer authorized. + Its prose cannot be safely decomposed by source after access changes. +6. A Global Ask session is rejected and restarted when any citation in its persisted + continuity context is no longer authorized. Stored summaries are not reused across + that boundary. +7. Ask retrieval itself applies the same cutoff and source eligibility before an LLM sees + evidence. Prompt bodies, hidden IDs, and unauthorized project counts never enter the + project-history link response. +8. Timeline or TEPP failure does not remove the answer; the Buyer receives an actionable + error and can still open the exact cited source post. + +## Consequences + +- Document reading, post Ask, Global Ask, and the dedicated Project history destination + share one authorization-first read model and one timeline component. +- Historical answers can disappear after permission or publication changes. This is an + intentional fail-closed property, not data loss from the evidence store. +- A session restart can lose conversational convenience, but prevents a compressed + summary from carrying hidden prose forward. +- Event order remains a temporal association and is not presented as causal inference. + +## Rejected alternatives + +- Parse project identities from answer prose. This is nondeterministic and ungrounded. +- Build a second project query or timeline inside Ask. This duplicates authority. +- Return a stored answer while merely hiding its citation chips. The prose may still leak + the hidden source. +- Keep a stale Global Ask summary and filter only new citations. The summary cannot be + safely decomposed after authorization changes. diff --git a/docs/adr/0125-global-ask-cutoff-and-migration-identity.md b/docs/adr/0125-global-ask-cutoff-and-migration-identity.md new file mode 100644 index 000000000..48745b065 --- /dev/null +++ b/docs/adr/0125-global-ask-cutoff-and-migration-identity.md @@ -0,0 +1,40 @@ +# ADR 0125 — Bind Global Ask cutoffs and keep migration identities unique + +**Decision status:** Accepted on the PR #342 repair branch +**Date:** 2026-08-21 +**Figma File ID:** N/A — this is a backend, migration, and operability decision. + +## Context + +Global Ask restricts source posts by the requested knowledge cutoff. Its final +PostgreSQL query used the `$4` cutoff placeholder but supplied only three +arguments, so a real PostgreSQL execution could fail before returning any +authorized evidence. The same branch also introduced a second forward +migration with numeric prefix `0053`, colliding with an existing migration. +Temporary self-modifying workflows were compensating for both defects after a +push rather than leaving the branch itself correct. + +## Decision + +1. Bind the cutoff as the fourth argument of the final Global Ask source query. +2. Assign the cutoff schema change the next unique forward migration identity, + `0054`, and update rollback, migration dispatch, and contract tests. +3. Keep reproduction and regression checks in committed tests. Do not use a + workflow that edits, commits, pushes, or deletes product source at runtime. + +## Consequences + +- Global Ask fails neither at PostgreSQL parameter binding nor by silently + dropping the requested knowledge cutoff. +- Migration replay and rollback address one numeric identity unambiguously. +- Hosted CI evaluates the exact committed source instead of a workflow-mutated + branch state. + +## Verification + +- The synthetic query contract asserts the fourth argument is the requested + cutoff. +- The PostgreSQL integration contract executes the final query against a real + local PostgreSQL parser when `LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN` is set. +- Migration identity tests reject duplicate numeric prefixes and require the + `0054_*` dispatch path. diff --git a/docs/adr/0127-recover-tepp-validation-on-canonical-project-history.md b/docs/adr/0127-recover-tepp-validation-on-canonical-project-history.md new file mode 100644 index 000000000..5a42d964e --- /dev/null +++ b/docs/adr/0127-recover-tepp-validation-on-canonical-project-history.md @@ -0,0 +1,98 @@ +# ADR 0127: Recover TEPP validation on the canonical project history + +- Status: Proposed +- Date: 2026-08-21 +- Depends on: LineageWeave Project history stack; `ContextualWisdomLab/TEPP#159` +- Supersedes: the duplicate project-history implementation carried by LineageWeave #281/#282 + +## Context + +A Buyer project-history timeline was implemented on a canonical, authorization-first +LineageWeave read model. An earlier TEPP integration was then left behind in a closed +parent PR and an open child PR whose branch reimplemented the project query, event +classification, and timeline. The user-supplied product screen requires one project +lifecycle timeline and an optional TEPP-linked answer, not two competing histories. + +The TEPP contract in PR #159 accepts only an exact project identity, a knowledge cutoff, +a focus event, and explicit source-grounded events. It may order those events and return +coded temporal-association findings. It does not accept or return a latent score, a +probability of causation, or an authoritative assignment record. + +## Decision + +1. LineageWeave remains authoritative for RBAC/ABAC, source eligibility, exact project + identity, event classification, visible responsibility evidence, and the Buyer + timeline. +2. The TEPP request is derived from that already-authorized canonical projection. No + second database query or second timeline component is allowed. +3. Source-post creation time is sent as both `occurred_at` and `available_at` only because + the canonical timeline explicitly declares it as the current fallback clock. The UI + continues to disclose that limitation. +4. Actor names and local actor keys do not cross the service boundary. TEPP receives a + deterministic opaque SHA-256 reference scoped to the authorized workspace. This is a + data-minimizing pseudonymous reference, not a claim of irreversible anonymization. +5. Evidence text is bounded and composed from the event title and persisted source-state + fields. Post bodies, browser tokens, review credentials, provider keys, and + `TEPP_API_KEY` are not forwarded. +6. The client requires the exact versioned field set, exact event cardinality and content, + deterministic chronological ordering, unchanged project/focus/cutoff identity, and + evidence-derived participant counts. Unknown fields, changed evidence, or a response above + TEPP's published 256 KiB contract limit fail closed before JSON decoding. +7. Accepted findings are limited to the six published TEPP #159 finding codes. Duplicate + event or evidence references are rejected. Buyer UI copy is owned by LineageWeave and + keyed by those codes; provider-authored summary prose is retained for contract + validation but is not rendered as the interpretation. +8. `temporal_association_only` is the only accepted inference status. Buyer copy states + that the result does not identify a cause. +9. A transport outage is distinct from an invalid response. `not_configured`, + `unavailable`, and `invalid_evidence` states leave the canonical timeline readable and + tell the operator or Buyer what to do next. +10. Global Ask and post-scoped Ask are a subsequent stacked slice and must reuse this same + canonical projection and TEPP envelope. +11. Any unexpected TEPP transport/provider exception is converted to the stable + `TEPP transport request failed` state. Raw response bodies and exception text remain + internal chained causes and never cross the public contract. + +## Consequences + +- The previously implemented capability is recovered without reviving the orphaned + duplicate stack. +- A TEPP outage cannot remove or alter authorized LineageWeave evidence. +- TEPP findings remain inspectable through exact source-post references. +- An unrecognized finding vocabulary cannot introduce provider-authored Buyer claims. +- The product does not answer “what caused the VOC?” as a causal claim. It answers which + explicit prior records are temporally associated and provides evidence for human review. +- A future distinct event-time or available-time source can replace the current fallback + only through a versioned contract and migration. + +## Rejected alternatives + +- **Merge the old #282 branch as-is.** It is based on a closed parent and carries a second + project-history implementation with a large unrelated ancestry. +- **Let TEPP query the LineageWeave database.** This breaks authorization ownership and + modular deployment. +- **Send full post bodies or actor names.** These are unnecessary for the published + temporal contract and expand the privacy boundary. +- **Render a separate TEPP timeline.** Duplicate timelines can disagree and obscure which + system owns evidence selection. +- **Render arbitrary TEPP summary prose.** The provider may validate time, but it does not + own Buyer-facing interpretation or an open-ended claim vocabulary. +- **Describe preceding events as causes.** Event order alone does not identify causality. + +## References + +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of +the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434 + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. +https://www.w3.org/TR/prov-o/ + +World Wide Web Consortium. (2017). *Time ontology in OWL*. +https://www.w3.org/TR/owl-time/ + +MITRE. (n.d.). *CWE-209: Generation of error message containing sensitive information*. +https://cwe.mitre.org/data/definitions/209.html + +National Institute of Standards and Technology. (2020). *Security and privacy controls +for information systems and organizations: NIST SP 800-53 Rev. 5*. +https://doi.org/10.6028/NIST.SP.800-53r5 diff --git a/docs/adr/0125-customer-master-three-pane-workspace.md b/docs/adr/0129-customer-master-three-pane-workspace.md similarity index 99% rename from docs/adr/0125-customer-master-three-pane-workspace.md rename to docs/adr/0129-customer-master-three-pane-workspace.md index b6ae71a74..3f83cd7fb 100644 --- a/docs/adr/0125-customer-master-three-pane-workspace.md +++ b/docs/adr/0129-customer-master-three-pane-workspace.md @@ -1,4 +1,4 @@ -# ADR 0125: Customer-centered three-pane Customer Master workspace +# ADR 0129: Customer-centered three-pane Customer Master workspace - **Status:** Accepted - **Date:** 2026-08-21 diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 8e29fd804..1f30d0d79 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -309,10 +309,45 @@ projection: update the affected FR/NFR row and Gap closure evidence when an ADR or PR changes product behavior. Never turn a PR title, green unit test, or old runtime note into a shipped/live claim. +## Recovered TEPP project-history integration (2026-08-21) + +- The canonical Buyer project timeline remains owned by the stacked Project history PR. +- The previously implemented TEPP work had become stranded in a closed parent and an + orphaned duplicate stack. This recovery consumes the canonical timeline instead of + introducing another project query, classifier, or timeline component. +- The dependency is the exact `ContextualWisdomLab/TEPP#159` project-history contract. + Until that contract is merged and a TEPP endpoint is deployed, the UI reports an + actionable fail-closed state and keeps the authorized LineageWeave timeline readable. +- TEPP receives opaque actor references and bounded source-field evidence only. Browser, + review, provider, and `TEPP_API_KEY` credentials are not forwarded. +- `temporal_association_only` is the maximum accepted authority. Buyer copy must say + that a preceding event is related in time, not that it caused the VOC. +- The next stacked slice attaches this same canonical timeline and TEPP metadata to + Global Ask and post-scoped Ask without re-retrieving hidden evidence. + +## Ask-to-project-history integration (2026-08-21) + +- Protected-stack checkpoint: PR #342 is based on PR #339 head + `43262dc76622928fdf90b922653949b4ac7c6631`; the PR description and hosted Checks + record its exact current head. Both remain review/check gated and are not represented + as merged production behavior. +- Post-scoped Ask and Global Ask return structured project-history links only for exact + project identities on their currently authorized cited posts. +- Opening a link lazily calls the canonical Project history endpoint with the answer + knowledge cutoff and cited focus post; no second timeline, classifier, or TEPP query is + implemented in either Ask surface. +- Source publication eligibility and cutoff are applied before Ask retrieval. Persisted + answers are withheld when any citation loses visibility, and a Global Ask session with + stale citations must start a new session before prior answer prose is reused. +- The response bounds citation and project counts, discloses truncated project links, and + keeps answers readable when a timeline or TEPP validation is unavailable. +- Remaining causal-analysis work is explicitly outside this slice: temporal association + and evidence navigation do not identify why a VOC occurred. + ## Current stacked PR product-surface gaps - **Customer Master relationship composition — PR #262**: Resolved on the - current feature branch. ADR 0125 and Figma frames `313:2` / `314:2` define a + current feature branch. ADR 0129 and Figma frames `313:2` / `314:2` define a customer-centered three-pane workspace that keeps the selected customer stable while the user inspects relationships and source posts. - **Responsive Customer Master flow — PR #262**: Resolved on the current diff --git a/frontend/package.json b/frontend/package.json index f216081b9..bd8c9ff59 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.18.0", + "version": "2.20.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 032deac1f..9b6d51b10 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -76,6 +76,7 @@ import { type PostLineage, type PostSummary, type PostSortOrder, + type ProjectHistoryLink, type RankingList, type PersonRoleHistoryEntry, type RelatedNode, @@ -92,6 +93,7 @@ import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; import { FiveW1H } from "./components/FiveW1H"; +import { AskProjectHistoryLinks } from "./components/AskProjectHistoryLinks"; import { ProjectHistoryTimeline } from "./components/ProjectHistoryTimeline"; import { normalizeProjectIdentity, @@ -294,6 +296,9 @@ function ChatPanel({ answer_text: result.answer_text, cited_post_ids: result.cited_post_ids, cited_posts: result.cited_posts, + knowledge_cutoff: result.knowledge_cutoff, + project_histories: result.project_histories, + project_histories_truncated: result.project_histories_truncated, }; return [...prev.filter((row) => row.question_text !== next.question_text), next]; }); @@ -336,6 +341,12 @@ function ChatPanel({ exchanges[0].cited_posts?.[0]?.post_id ?? exchanges[0].cited_post_ids[0] } /> + ) : null} {nameFirstAsk && firstCitedTitle ? ( @@ -416,6 +427,12 @@ function ChatPanel({ citedPostIds={exchange.cited_post_ids} onOpenEvidence={setEvidencePostId} /> + ))} {answer && !exchanges.some((row) => row.answer_text === answer.answer_text) && ( @@ -426,6 +443,12 @@ function ChatPanel({ citedPostIds={answer.cited_post_ids} onOpenEvidence={setEvidencePostId} /> + )} {!nameFirstAsk && evidencePostId ? ( @@ -4588,6 +4611,12 @@ function AskAgentPanel({ window.sessionStorage.getItem(GLOBAL_ASK_SESSION_STORAGE_KEY) ?? undefined, ); + function acceptAnswer(nextAnswer: AskAgentResponse) { + setAnswer(nextAnswer); + setSessionId(nextAnswer.session_id); + window.sessionStorage.setItem("lineageweave.globalAskSessionId", nextAnswer.session_id); + } + async function handleAsk() { const normalized = question.trim(); if (!normalized) return; @@ -4606,11 +4635,19 @@ function AskAgentPanel({ window.sessionStorage.removeItem(GLOBAL_ASK_SESSION_STORAGE_KEY); nextAnswer = await askAgent(accessToken, normalized); } - setAnswer(nextAnswer); - setSessionId(nextAnswer.session_id); - window.sessionStorage.setItem(GLOBAL_ASK_SESSION_STORAGE_KEY, nextAnswer.session_id); + acceptAnswer(nextAnswer); } catch (err) { - setAnswer(null); + if (err instanceof BackendError && err.status === 409 && sessionId) { + window.sessionStorage.removeItem("lineageweave.globalAskSessionId"); + setSessionId(undefined); + try { + acceptAnswer(await askAgent(accessToken, normalized)); + return; + } catch (retryError) { + setError(orchestratorUnavailableMessage(retryError, t("Ask Agent"))); + return; + } + } setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); } finally { setAsking(false); @@ -4660,6 +4697,12 @@ function AskAgentPanel({ ) : null} + {answer.cited_posts && answer.cited_posts.length > 0 && ( <>

diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1d3bb5a88..838609094 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -282,12 +282,24 @@ export interface CitedPostEvidence { facts: CitedPostEvidenceFact[]; } +export interface ProjectHistoryLink { + project_key: string; + project_name: string; + focus_post_id: string; + source_post_ids: string[]; + knowledge_cutoff: string; + truth_status_code: "observed" | "inferred"; +} + export interface ChatAnswer { post_id: string; answer_text: string; cited_post_ids: string[]; cited_posts?: CitedPostRef[]; source_post_ids: string[]; + knowledge_cutoff?: string; + project_histories?: ProjectHistoryLink[]; + project_histories_truncated?: boolean; } export interface ChatExchange { @@ -295,6 +307,9 @@ export interface ChatExchange { answer_text: string; cited_post_ids: string[]; cited_posts?: CitedPostRef[]; + knowledge_cutoff?: string; + project_histories?: ProjectHistoryLink[]; + project_histories_truncated?: boolean; } export interface ChatHistory { @@ -310,6 +325,9 @@ export interface AskAgentResponse { cited_post_evidence?: CitedPostEvidence[]; source_post_ids: string[]; timeline?: AskTimelineEntry[]; + knowledge_cutoff?: string; + project_histories?: ProjectHistoryLink[]; + project_histories_truncated?: boolean; next_action?: string; } diff --git a/frontend/src/components/AskProjectHistoryLinks.css b/frontend/src/components/AskProjectHistoryLinks.css new file mode 100644 index 000000000..8a491419d --- /dev/null +++ b/frontend/src/components/AskProjectHistoryLinks.css @@ -0,0 +1,36 @@ +.ask-project-history-links { + display: grid; + gap: 0.75rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color, #d7dce5); +} + +.ask-project-history-links > h4, +.ask-project-history-link p { + margin: 0; +} + +.ask-project-history-link { + display: grid; + gap: 0.625rem; + padding: 0.75rem; + border: 1px solid var(--border-color, #d7dce5); + border-radius: 0.75rem; + background: var(--surface-color, #fff); +} + +.ask-project-history-link > div:first-child { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.ask-project-history-link > button { + justify-self: start; +} + +.ask-project-history-link [hidden] { + display: none; +} diff --git a/frontend/src/components/AskProjectHistoryLinks.stories.tsx b/frontend/src/components/AskProjectHistoryLinks.stories.tsx new file mode 100644 index 000000000..b3b25fd16 --- /dev/null +++ b/frontend/src/components/AskProjectHistoryLinks.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { AskProjectHistoryLinks } from "./AskProjectHistoryLinks"; + +const meta = { + title: "Buyer/Ask Project History Links", + component: AskProjectHistoryLinks, + args: { + accessToken: "storybook-token", + links: [ + { + project_key: "P-100", + project_name: "Synthetic renewal", + focus_post_id: "post-voc", + source_post_ids: ["post-spec", "post-voc"], + knowledge_cutoff: "2026-08-20T12:00:00Z", + truth_status_code: "observed", + }, + ], + truncated: false, + onOpenPost: () => undefined, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ObservedProject: Story = {}; + +export const InferredAndTruncated: Story = { + args: { + links: [ + { + project_key: "semantic-project", + project_name: "Semantic project candidate", + focus_post_id: "post-candidate", + source_post_ids: ["post-candidate"], + knowledge_cutoff: "2026-08-20T12:00:00Z", + truth_status_code: "inferred", + }, + ], + truncated: true, + }, +}; diff --git a/frontend/src/components/AskProjectHistoryLinks.test.tsx b/frontend/src/components/AskProjectHistoryLinks.test.tsx new file mode 100644 index 000000000..16da5ce7c --- /dev/null +++ b/frontend/src/components/AskProjectHistoryLinks.test.tsx @@ -0,0 +1,123 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchProjectHistory } from "../api"; +import type { ProjectHistoryProjection } from "../projectHistory"; +import { AskProjectHistoryLinks } from "./AskProjectHistoryLinks"; + +vi.mock("../api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchProjectHistory: vi.fn(), + }; +}); + +const projection: ProjectHistoryProjection = { + contract_version: 1, + project_key: "P-100", + normalized_project_key: "p-100", + project_name: "Synthetic renewal", + focus_event_id: "post-voc", + time_basis_code: "source_post_created_at_fallback", + knowledge_cutoff: "2026-08-20T12:00:00Z", + evidence_boundary_code: "authorized_visible_source_posts", + event_count: 1, + distinct_actor_count: 0, + distinct_observed_actor_count: 0, + truncated: false, + events: [ + { + event_id: "post-voc", + source_post_id: "post-voc", + event_title: "Synthetic VOC received", + event_type_code: "voc_received", + event_type_basis_code: "display_classification", + occurred_at: "2026-02-02T09:00:00Z", + time_basis_code: "source_post_created_at_fallback", + voc_type_code: "voc", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + responsibility_evidence: [], + observed_responsibilities: [], + responsibility_transition_code: null, + responsibility_transition_truth_status_code: null, + related_prior_paths: [], + }, + ], +}; + +const link = { + project_key: "P-100", + project_name: "Synthetic renewal", + focus_post_id: "post-voc", + source_post_ids: ["post-voc"], + knowledge_cutoff: "2026-08-20T12:00:00Z", + truth_status_code: "observed" as const, +}; + +describe("AskProjectHistoryLinks", () => { + beforeEach(() => { + vi.mocked(fetchProjectHistory).mockReset(); + }); + + it("loads the canonical timeline at the answer cutoff and preserves source navigation", async () => { + const onOpenPost = vi.fn(); + vi.mocked(fetchProjectHistory).mockResolvedValue(projection); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /open project history: Synthetic renewal/i })); + + await waitFor(() => { + expect(fetchProjectHistory).toHaveBeenCalledWith( + "token", + "P-100", + "2026-08-20T12:00:00Z", + "post-voc", + ); + }); + expect(screen.getByRole("heading", { name: /project event timeline/i })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /open source record: Synthetic VOC received/i })); + expect(onOpenPost).toHaveBeenCalledWith("post-voc"); + }); + + it("reports truncation and leaves the answer readable when the timeline fetch fails", async () => { + vi.mocked(fetchProjectHistory).mockRejectedValue(new Error("synthetic failure")); + + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent(/additional cited projects are not shown/i); + fireEvent.click(screen.getByRole("button", { name: /open project history: Synthetic renewal/i })); + expect(await screen.findByRole("alert")).toHaveTextContent(/project history could not be loaded/i); + expect(screen.getByText("Synthetic renewal")).toBeInTheDocument(); + }); + + it("renders nothing when the answer cites no project identity", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/src/components/AskProjectHistoryLinks.tsx b/frontend/src/components/AskProjectHistoryLinks.tsx new file mode 100644 index 000000000..99d144b43 --- /dev/null +++ b/frontend/src/components/AskProjectHistoryLinks.tsx @@ -0,0 +1,177 @@ +import { useEffect, useId, useState } from "react"; + +import { fetchProjectHistory, type ProjectHistoryLink } from "../api"; +import type { Locale } from "../i18n"; +import { useLocale } from "../i18n"; +import { projectHistoryText, type ProjectHistoryProjection } from "../projectHistory"; +import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline"; +import "./AskProjectHistoryLinks.css"; + +interface Copy { + heading: string; + boundary: string; + open: (name: string) => string; + close: (name: string) => string; + loading: string; + truncated: string; + observed: string; + inferred: string; +} + +const COPY: Record = { + en: { + heading: "Project histories cited by this answer", + boundary: "Each timeline is rebuilt from currently authorized evidence at the answer cutoff.", + open: (name) => `Open project history: ${name}`, + close: (name) => `Close project history: ${name}`, + loading: "Loading cited project history...", + truncated: "Additional cited projects are not shown. Open the cited source records to inspect their project evidence.", + observed: "Observed project identity", + inferred: "Inferred project identity", + }, + ko: { + heading: "이 답변이 인용한 프로젝트 이력", + boundary: "각 타임라인은 답변 기준 시각과 현재 권한을 통과한 근거로 다시 구성됩니다.", + open: (name) => `프로젝트 이력 열기: ${name}`, + close: (name) => `프로젝트 이력 닫기: ${name}`, + loading: "인용된 프로젝트 이력을 불러오는 중...", + truncated: "일부 추가 프로젝트는 표시하지 않습니다. 인용된 원천 기록에서 프로젝트 근거를 확인하세요.", + observed: "관찰된 프로젝트 식별자", + inferred: "추론된 프로젝트 식별자", + }, + zh: { + heading: "此回答引用的项目历史", + boundary: "每条时间线都根据回答截止时间和当前授权证据重新构建。", + open: (name) => `打开项目历史:${name}`, + close: (name) => `关闭项目历史:${name}`, + loading: "正在加载引用的项目历史...", + truncated: "还有引用项目未显示。请打开引用的源记录检查其项目依据。", + observed: "已观察的项目身份", + inferred: "已推断的项目身份", + }, + ja: { + heading: "この回答が引用したプロジェクト履歴", + boundary: "各タイムラインは回答時点と現在の権限を通過した根拠から再構成されます。", + open: (name) => `プロジェクト履歴を開く: ${name}`, + close: (name) => `プロジェクト履歴を閉じる: ${name}`, + loading: "引用されたプロジェクト履歴を読み込み中...", + truncated: "追加の引用プロジェクトは表示されていません。引用元レコードでプロジェクト根拠を確認してください。", + observed: "観察されたプロジェクト識別子", + inferred: "推論されたプロジェクト識別子", + }, + vi: { + heading: "Lịch sử dự án được câu trả lời này trích dẫn", + boundary: "Mỗi dòng thời gian được dựng lại từ bằng chứng hiện được cấp quyền tại thời điểm cắt của câu trả lời.", + open: (name) => `Mở lịch sử dự án: ${name}`, + close: (name) => `Đóng lịch sử dự án: ${name}`, + loading: "Đang tải lịch sử dự án được trích dẫn...", + truncated: "Một số dự án được trích dẫn chưa được hiển thị. Hãy mở bản ghi nguồn để kiểm tra bằng chứng dự án.", + observed: "Danh tính dự án được quan sát", + inferred: "Danh tính dự án được suy luận", + }, +}; + +function ProjectHistoryDisclosure({ + accessToken, + link, + onOpenPost, +}: { + accessToken: string; + link: ProjectHistoryLink; + onOpenPost: (postId: string) => void; +}) { + const locale = useLocale(); + const copy = COPY[locale]; + const regionId = useId(); + const [opened, setOpened] = useState(false); + const [loading, setLoading] = useState(false); + const [projection, setProjection] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + setOpened(false); + setLoading(false); + setProjection(null); + setError(false); + }, [link.project_key, link.focus_post_id, link.knowledge_cutoff]); + + function toggle() { + if (opened) { + setOpened(false); + return; + } + setOpened(true); + if (projection || loading) return; + setLoading(true); + setError(false); + fetchProjectHistory( + accessToken, + link.project_key, + link.knowledge_cutoff, + link.focus_post_id, + ) + .then((result) => { + setProjection(result); + setLoading(false); + }) + .catch(() => { + setError(true); + setLoading(false); + }); + } + + return ( +

+
+ {link.project_name} + + {link.truth_status_code === "observed" ? copy.observed : copy.inferred} + +
+ + +
+ ); +} + +export function AskProjectHistoryLinks({ + accessToken, + links, + truncated, + onOpenPost, +}: { + accessToken: string; + links: ProjectHistoryLink[]; + truncated: boolean; + onOpenPost: (postId: string) => void; +}) { + const locale = useLocale(); + const copy = COPY[locale]; + const headingId = useId(); + if (links.length === 0 && !truncated) return null; + return ( +
+

{copy.heading}

+

{copy.boundary}

+ {links.map((link) => ( + + ))} + {truncated ?

{copy.truncated}

: null} +
+ ); +} diff --git a/frontend/src/components/ProjectHistoryTimeline.tepp.test.tsx b/frontend/src/components/ProjectHistoryTimeline.tepp.test.tsx new file mode 100644 index 000000000..e33ad1ff8 --- /dev/null +++ b/frontend/src/components/ProjectHistoryTimeline.tepp.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ProjectHistoryProjection } from "../projectHistory"; +import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline"; + +const projection = { + contract_version: 1, + project_key: "P-100", + normalized_project_key: "p-100", + project_name: "Synthetic transformer renewal", + focus_event_id: "voc", + time_basis_code: "source_post_created_at_fallback", + knowledge_cutoff: "2026-08-20T12:00:00Z", + evidence_boundary_code: "authorized_visible_source_posts", + event_count: 1, + distinct_actor_count: 0, + distinct_observed_actor_count: 0, + truncated: false, + tepp_validation: { + status: "validated", + next_action_code: "open_source_evidence", + project_history: { + contract_version: 1, + project_key: "P-100", + project_name: "Synthetic transformer renewal", + focus_event_id: "voc", + knowledge_cutoff: "2026-08-20T12:00:00Z", + history_span_start: "2026-02-02T09:00:00Z", + history_span_end: "2026-02-02T09:00:00Z", + participant_count: 0, + inference_status: "temporal_association_only", + event_count: 1, + findings: [], + }, + }, + events: [ + { + event_id: "voc", + source_post_id: "post-voc", + event_title: "Synthetic VOC received", + event_type_code: "voc_received", + event_type_basis_code: "display_classification", + occurred_at: "2026-02-02T09:00:00Z", + time_basis_code: "source_post_created_at_fallback", + voc_type_code: "voc", + source_stage_code: null, + source_detail_state_code: null, + project_matches: [], + responsibility_evidence: [], + observed_responsibilities: [], + responsibility_transition_code: null, + responsibility_transition_truth_status_code: null, + related_prior_paths: [], + }, + ], +} as ProjectHistoryProjection; + +describe("ProjectHistoryTimeline TEPP integration", () => { + it("renders TEPP validation on the canonical timeline instead of a duplicate timeline", () => { + render(); + + expect(screen.getByRole("heading", { name: /TEPP temporal validation/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /Project event timeline/i })).toBeInTheDocument(); + expect(screen.getAllByRole("tab")).toHaveLength(1); + }); +}); diff --git a/frontend/src/components/ProjectHistoryTimeline.tsx b/frontend/src/components/ProjectHistoryTimeline.tsx index a7832716a..12c7a7854 100644 --- a/frontend/src/components/ProjectHistoryTimeline.tsx +++ b/frontend/src/components/ProjectHistoryTimeline.tsx @@ -8,6 +8,7 @@ import { projectHistoryText, projectHistoryTransitionLabel, } from "../projectHistory"; +import { TeppProjectHistoryEvidence } from "./TeppProjectHistoryEvidence"; import "./ProjectHistoryTimeline.css"; function formatDate(value: string): string { @@ -124,6 +125,16 @@ export function ProjectHistoryTimeline({

) : null} + {projection.tepp_validation ? ( + [event.source_post_id, event.event_title]), + )} + /> + ) : null} +
header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.tepp-project-evidence h4, +.tepp-project-evidence h5, +.tepp-project-evidence p { + margin-top: 0; +} + +.tepp-project-evidence-boundary { + font-weight: 700; +} + +.tepp-project-evidence dl { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 0.75rem; + margin: 1rem 0; +} + +.tepp-project-evidence dl > div { + padding: 0.75rem; + border-radius: 0.65rem; + background: color-mix(in srgb, var(--surface-color, #ffffff) 88%, transparent); +} + +.tepp-project-evidence dt { + font-size: 0.85rem; + font-weight: 700; +} + +.tepp-project-evidence dd { + margin: 0.3rem 0 0; +} + +.tepp-project-evidence ul { + display: grid; + gap: 0.75rem; + padding-left: 1.25rem; +} + +.tepp-project-evidence-links { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tepp-project-evidence-links button { + min-height: 2.75rem; +} + +.tepp-project-evidence-status { + border-style: dashed; +} + +@media (max-width: 42rem) { + .tepp-project-evidence > header { + flex-direction: column; + } +} + +@media print { + .tepp-project-evidence-links button { + border: 0; + padding: 0; + background: none; + } +} diff --git a/frontend/src/components/TeppProjectHistoryEvidence.stories.tsx b/frontend/src/components/TeppProjectHistoryEvidence.stories.tsx new file mode 100644 index 000000000..0a3833ddb --- /dev/null +++ b/frontend/src/components/TeppProjectHistoryEvidence.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { TeppProjectHistoryEvidence } from "./TeppProjectHistoryEvidence"; + +const meta = { + title: "Buyer/TEPP Project History Evidence", + component: TeppProjectHistoryEvidence, + args: { + validation: { + status: "validated", + next_action_code: "open_source_evidence", + project_history: { + contract_version: 1, + project_key: "P-100", + project_name: "Synthetic transformer renewal", + focus_event_id: "voc", + knowledge_cutoff: "2026-08-20T12:00:00Z", + history_span_start: "2022-03-11T09:00:00Z", + history_span_end: "2026-02-02T09:00:00Z", + participant_count: 2, + inference_status: "temporal_association_only", + event_count: 3, + findings: [ + { + finding_code: "specification_change_before_focus", + summary: "An explicit specification-change event precedes the focus event.", + related_event_ids: ["spec"], + evidence_post_ids: ["post-spec"], + }, + ], + }, + }, + sourceLabels: { "post-spec": "Synthetic specification changed" }, + onOpenPost: () => undefined, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Validated: Story = {}; + +export const NotConfigured: Story = { + args: { + validation: { + status: "not_configured", + project_history: null, + next_action_code: "configure_tepp_project_history", + }, + }, +}; + +export const ServiceUnavailable: Story = { + args: { + validation: { + status: "unavailable", + project_history: null, + next_action_code: "retry_tepp_project_history", + }, + }, +}; diff --git a/frontend/src/components/TeppProjectHistoryEvidence.test.tsx b/frontend/src/components/TeppProjectHistoryEvidence.test.tsx new file mode 100644 index 000000000..21cc046f6 --- /dev/null +++ b/frontend/src/components/TeppProjectHistoryEvidence.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { TeppProjectHistoryValidation } from "../projectHistory"; +import { TeppProjectHistoryEvidence } from "./TeppProjectHistoryEvidence"; + +const validation: TeppProjectHistoryValidation = { + status: "validated", + next_action_code: "open_source_evidence", + project_history: { + contract_version: 1, + project_key: "P-100", + project_name: "Synthetic transformer renewal", + focus_event_id: "voc", + knowledge_cutoff: "2026-08-20T12:00:00Z", + history_span_start: "2022-03-11T09:00:00Z", + history_span_end: "2026-02-02T09:00:00Z", + participant_count: 2, + inference_status: "temporal_association_only", + event_count: 3, + findings: [ + { + finding_code: "specification_change_before_focus", + summary: "An explicit specification-change event precedes the focus event.", + related_event_ids: ["spec"], + evidence_post_ids: ["post-spec"], + }, + ], + }, +}; + +describe("TeppProjectHistoryEvidence", () => { + it("shows controlled TEPP copy and opens only supplied source evidence", () => { + const onOpenPost = vi.fn(); + render( + , + ); + + expect(screen.getByRole("heading", { name: /TEPP temporal validation/i })).toBeInTheDocument(); + expect(screen.getByText(/temporal association only/i)).toBeInTheDocument(); + expect(screen.getByText(/does not identify a cause/i)).toBeInTheDocument(); + expect(screen.getByText(/participants in supplied evidence/i)).toBeInTheDocument(); + expect(screen.getByText("2", { selector: "dd" })).toBeInTheDocument(); + expect( + screen.queryByText("An explicit specification-change event precedes the focus event."), + ).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: /open evidence: Synthetic specification changed/i }), + ); + expect(onOpenPost).toHaveBeenCalledWith("post-spec"); + }); + + it("gives an actionable fail-closed state without inventing a result", () => { + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent(/configure the TEPP project-history endpoint/i); + expect(screen.queryByText(/participants in supplied evidence/i)).not.toBeInTheDocument(); + }); + + it("uses unique labelled-region ids when more than one evidence panel is present", () => { + render( + <> + + + , + ); + + const headings = screen.getAllByRole("heading", { name: /TEPP temporal validation/i }); + const regions = headings.map((heading) => heading.closest("section")); + expect(headings[0].id).not.toBe(headings[1].id); + expect(regions[0]).toHaveAttribute("aria-labelledby", headings[0].id); + expect(regions[1]).toHaveAttribute("aria-labelledby", headings[1].id); + }); + + it("fails closed when a validated response has no metadata", () => { + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent(/open the source evidence/i); + }); +}); diff --git a/frontend/src/components/TeppProjectHistoryEvidence.tsx b/frontend/src/components/TeppProjectHistoryEvidence.tsx new file mode 100644 index 000000000..7cdddbb1d --- /dev/null +++ b/frontend/src/components/TeppProjectHistoryEvidence.tsx @@ -0,0 +1,238 @@ +import { useId } from "react"; + +import type { Locale } from "../i18n"; +import { useLocale } from "../i18n"; +import type { + TeppProjectHistoryFindingCode, + TeppProjectHistoryValidation, +} from "../projectHistory"; +import "./TeppProjectHistoryEvidence.css"; + +interface Copy { + heading: string; + eyebrow: string; + boundary: string; + participants: string; + span: string; + findings: string; + noFindings: string; + openEvidence: (label: string) => string; + unnamedEvidence: (index: number) => string; + status: Record, string>; + findingLabels: Record; +} + +const COPY: Record = { + en: { + heading: "TEPP temporal validation", + eyebrow: "TEPP-connected evidence", + boundary: "Temporal association only; this does not identify a cause.", + participants: "Participants in supplied evidence", + span: "Validated history span", + findings: "TEPP findings", + noFindings: "TEPP ordered the explicit events and returned no additional finding.", + openEvidence: (label) => `Open evidence: ${label}`, + unnamedEvidence: (index) => `Evidence record ${index}`, + status: { + not_configured: "Configure the TEPP project-history endpoint, then retry this timeline.", + unavailable: "TEPP is unavailable. Read the canonical timeline now and retry validation later.", + invalid_evidence: "Open the source evidence and correct the project-history contract before retrying TEPP.", + }, + findingLabels: { + contract_award_before_focus: "A contract-award event precedes the selected event.", + specification_change_before_focus: "A specification-change event precedes the selected event.", + delivery_before_focus: "A delivery event precedes the selected event.", + handoff_before_focus: "A handoff record precedes the selected event.", + rebid_after_focus: "A rebid event follows the selected event.", + specification_change_and_handoff_before_focus: + "Specification-change and handoff records both precede the selected event.", + }, + }, + ko: { + heading: "TEPP 시간 검증", + eyebrow: "TEPP 연계 근거", + boundary: "시간적 연관만 제시하며 원인을 식별한 결과가 아닙니다.", + participants: "제공된 근거의 참여자", + span: "검증된 이력 구간", + findings: "TEPP 검토 결과", + noFindings: "TEPP가 명시적 이벤트를 정렬했으며 추가 검토 결과는 없습니다.", + openEvidence: (label) => `근거 열기: ${label}`, + unnamedEvidence: (index) => `근거 기록 ${index}`, + status: { + not_configured: "TEPP 프로젝트 이력 엔드포인트를 설정한 뒤 이 타임라인을 다시 검증하세요.", + unavailable: "TEPP를 사용할 수 없습니다. 현재는 기준 타임라인을 읽고 나중에 검증을 다시 실행하세요.", + invalid_evidence: "원천 근거를 열어 프로젝트 이력 계약을 바로잡은 뒤 TEPP를 다시 실행하세요.", + }, + findingLabels: { + contract_award_before_focus: "선택한 이벤트보다 앞선 수주 확정 기록이 있습니다.", + specification_change_before_focus: "선택한 이벤트보다 앞선 사양 변경 기록이 있습니다.", + delivery_before_focus: "선택한 이벤트보다 앞선 납품 기록이 있습니다.", + handoff_before_focus: "선택한 이벤트보다 앞선 인수인계 기록이 있습니다.", + rebid_after_focus: "선택한 이벤트 뒤에 재입찰 기록이 있습니다.", + specification_change_and_handoff_before_focus: + "선택한 이벤트보다 앞서 사양 변경과 인수인계 기록이 모두 있습니다.", + }, + }, + zh: { + heading: "TEPP 时间验证", + eyebrow: "TEPP 关联证据", + boundary: "仅表示时间关联,不等于识别了原因。", + participants: "所提供证据中的参与者", + span: "已验证的历史区间", + findings: "TEPP 结果", + noFindings: "TEPP 已对明确事件排序,未返回其他结果。", + openEvidence: (label) => `打开证据:${label}`, + unnamedEvidence: (index) => `证据记录 ${index}`, + status: { + not_configured: "请配置 TEPP 项目历史端点,然后重新验证此时间线。", + unavailable: "TEPP 当前不可用。请先阅读标准时间线,稍后重试验证。", + invalid_evidence: "请打开源证据并修正项目历史契约,然后重试 TEPP。", + }, + findingLabels: { + contract_award_before_focus: "合同授予记录早于所选事件。", + specification_change_before_focus: "规格变更记录早于所选事件。", + delivery_before_focus: "交付记录早于所选事件。", + handoff_before_focus: "交接记录早于所选事件。", + rebid_after_focus: "重新投标记录晚于所选事件。", + specification_change_and_handoff_before_focus: "规格变更和交接记录均早于所选事件。", + }, + }, + ja: { + heading: "TEPP 時間検証", + eyebrow: "TEPP 連携根拠", + boundary: "時間的関連のみを示し、原因を特定した結果ではありません。", + participants: "提供根拠の参加者", + span: "検証済み履歴期間", + findings: "TEPP の結果", + noFindings: "TEPP は明示的イベントを並べ替え、追加の結果は返しませんでした。", + openEvidence: (label) => `根拠を開く: ${label}`, + unnamedEvidence: (index) => `根拠記録 ${index}`, + status: { + not_configured: "TEPP プロジェクト履歴エンドポイントを設定し、このタイムラインを再検証してください。", + unavailable: "TEPP は利用できません。標準タイムラインを読み、後で検証を再試行してください。", + invalid_evidence: "原典根拠を開いてプロジェクト履歴契約を修正し、TEPP を再実行してください。", + }, + findingLabels: { + contract_award_before_focus: "選択イベントより前に受注確定記録があります。", + specification_change_before_focus: "選択イベントより前に仕様変更記録があります。", + delivery_before_focus: "選択イベントより前に納品記録があります。", + handoff_before_focus: "選択イベントより前に引継ぎ記録があります。", + rebid_after_focus: "選択イベントの後に再入札記録があります。", + specification_change_and_handoff_before_focus: "仕様変更と引継ぎの記録が選択イベントより前にあります。", + }, + }, + vi: { + heading: "Xác thực thời gian TEPP", + eyebrow: "Bằng chứng liên kết TEPP", + boundary: "Chỉ thể hiện mối liên hệ theo thời gian; kết quả này không xác định nguyên nhân.", + participants: "Chủ thể trong bằng chứng đã cung cấp", + span: "Khoảng lịch sử đã xác thực", + findings: "Kết quả TEPP", + noFindings: "TEPP đã sắp xếp các sự kiện tường minh và không trả về kết quả bổ sung.", + openEvidence: (label) => `Mở bằng chứng: ${label}`, + unnamedEvidence: (index) => `Bản ghi bằng chứng ${index}`, + status: { + not_configured: "Hãy cấu hình điểm cuối lịch sử dự án TEPP rồi xác thực lại dòng thời gian này.", + unavailable: "TEPP hiện không khả dụng. Hãy đọc dòng thời gian chuẩn và thử xác thực lại sau.", + invalid_evidence: "Hãy mở bằng chứng nguồn, sửa hợp đồng lịch sử dự án rồi chạy lại TEPP.", + }, + findingLabels: { + contract_award_before_focus: "Bản ghi trao hợp đồng có trước sự kiện được chọn.", + specification_change_before_focus: "Bản ghi thay đổi đặc tả có trước sự kiện được chọn.", + delivery_before_focus: "Bản ghi bàn giao có trước sự kiện được chọn.", + handoff_before_focus: "Bản ghi chuyển giao có trước sự kiện được chọn.", + rebid_after_focus: "Bản ghi đấu thầu lại có sau sự kiện được chọn.", + specification_change_and_handoff_before_focus: + "Các bản ghi thay đổi đặc tả và chuyển giao đều có trước sự kiện được chọn.", + }, + }, +}; + +function shortDate(value: string): string { + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) ? value : parsed.toISOString().slice(0, 10); +} + +export function TeppProjectHistoryEvidence({ + validation, + onOpenPost, + sourceLabels, +}: { + validation: TeppProjectHistoryValidation; + onOpenPost: (postId: string) => void; + sourceLabels: Record; +}) { + const locale = useLocale(); + const copy = COPY[locale]; + const headingId = useId(); + const findingsHeadingId = useId(); + + const history = validation.project_history; + if (validation.status !== "validated" || history === null) { + const statusMessage = + validation.status === "validated" + ? copy.status.invalid_evidence + : copy.status[validation.status]; + return ( +
+

{copy.heading}

+

{statusMessage}

+
+ ); + } + + return ( +
+
+
+

{copy.eyebrow}

+

{copy.heading}

+
+ TEPP · v{history.contract_version} +
+

{copy.boundary}

+
+
+
{copy.participants}
+
{history.participant_count}
+
+
+
{copy.span}
+
+ {shortDate(history.history_span_start)} – {shortDate(history.history_span_end)} +
+
+
+
+
{copy.findings}
+ {history.findings.length === 0 ?

{copy.noFindings}

: null} + {history.findings.length > 0 ? ( +
    + {history.findings.map((finding) => ( +
  • +

    {copy.findingLabels[finding.finding_code]}

    +
    + {finding.evidence_post_ids.map((postId, index) => { + const label = sourceLabels[postId] ?? copy.unnamedEvidence(index + 1); + return ( + + ); + })} +
    +
  • + ))} +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts index d02a9158c..c976baeb3 100644 --- a/frontend/src/projectHistory.ts +++ b/frontend/src/projectHistory.ts @@ -2,6 +2,45 @@ import type { ProjectEvidence } from "./api"; import type { Locale } from "./i18n"; export type ProjectHistoryTruthStatus = "observed" | "inferred"; + +export type TeppProjectHistoryFindingCode = + | "contract_award_before_focus" + | "specification_change_before_focus" + | "delivery_before_focus" + | "handoff_before_focus" + | "rebid_after_focus" + | "specification_change_and_handoff_before_focus"; + +export interface TeppProjectHistoryFinding { + finding_code: TeppProjectHistoryFindingCode; + summary: string; + related_event_ids: string[]; + evidence_post_ids: string[]; +} + +export interface TeppProjectHistoryMetadata { + contract_version: 1; + project_key: string; + project_name: string; + focus_event_id: string; + knowledge_cutoff: string; + history_span_start: string; + history_span_end: string; + participant_count: number; + inference_status: "temporal_association_only"; + event_count: number; + findings: TeppProjectHistoryFinding[]; +} + +export interface TeppProjectHistoryValidation { + status: "validated" | "not_configured" | "unavailable" | "invalid_evidence"; + project_history: TeppProjectHistoryMetadata | null; + next_action_code: + | "open_source_evidence" + | "configure_tepp_project_history" + | "retry_tepp_project_history"; +} + export type ResponsibilityTransitionCode = "continuous" | "handoff" | "assignment_gap"; export type ProjectHistoryTimeBasis = "source_post_created_at_fallback" | "document_time"; @@ -73,6 +112,7 @@ export interface ProjectHistoryProjection { distinct_actor_count?: number; distinct_observed_actor_count: number; truncated: boolean; + tepp_validation?: TeppProjectHistoryValidation; events: ProjectHistoryEvent[]; } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index fc9d18b25..2cb406a3f 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.18.0" +__version__ = "2.20.0" diff --git a/lineageweave/http_client.py b/lineageweave/http_client.py index 389b29f3e..9ef4dd40a 100644 --- a/lineageweave/http_client.py +++ b/lineageweave/http_client.py @@ -38,6 +38,7 @@ def _request( body: bytes | None, headers: dict[str, str], timeout: float, + maximum_response_bytes: int | None = None, ) -> tuple[int, bytes]: """Implement the _request operation for this channel.""" parsed = urlparse(url) @@ -68,8 +69,17 @@ def _request( ) connection.request(method, path, body=body, headers=headers) response = connection.getresponse() - length_header = response.getheader("Content-Length") - raw = response.read(int(length_header)) if length_header is not None else response.read() + if maximum_response_bytes is not None: + if maximum_response_bytes < 1: + raise ValueError("maximum_response_bytes must be positive") + raw = response.read(maximum_response_bytes + 1) + if len(raw) > maximum_response_bytes: + raise HttpClientError( + f"response exceeds {maximum_response_bytes} bytes" + ) + else: + length_header = response.getheader("Content-Length") + raw = response.read(int(length_header)) if length_header is not None else response.read() return response.status, raw finally: connection.close() @@ -105,15 +115,22 @@ def post_json( *, headers: dict[str, str], timeout: float, + include_llm_metadata: bool = True, + maximum_response_bytes: int | None = None, ) -> dict: """POST ``payload`` as JSON to ``url`` and return the decoded object. + ``include_llm_metadata`` preserves contextual-orchestrator enrichment by + default. Closed non-LLM wire contracts must set it to ``False`` so an active + LLM context cannot add an unpublished ``metadata`` member. + ``maximum_response_bytes`` bounds reads for strict remote contracts. + Raises: ValueError: ``url`` is not an ``http`` / ``https`` URL with a host. HttpClientError: the server responded with HTTP >= 400 or non-JSON. """ request_payload = payload - request_metadata = current_llm_metadata() + request_metadata = current_llm_metadata() if include_llm_metadata else None if request_metadata: request_payload = dict(payload) existing_metadata = request_payload.get("metadata") @@ -123,13 +140,14 @@ def post_json( request_payload["metadata"] = {**existing_metadata, **request_metadata} else: raise ValueError("metadata must be an object") - status, raw = _request( - "POST", - url, - body=json.dumps(request_payload).encode("utf-8"), - headers={"content-type": "application/json", **headers}, - timeout=timeout, - ) + request_options = { + "body": json.dumps(request_payload).encode("utf-8"), + "headers": {"content-type": "application/json", **headers}, + "timeout": timeout, + } + if maximum_response_bytes is not None: + request_options["maximum_response_bytes"] = maximum_response_bytes + status, raw = _request("POST", url, **request_options) hostname = urlparse(url).hostname or url if status >= 400: raise HttpClientError(f"HTTP {status} from {hostname}") diff --git a/lineageweave/tepp_client.py b/lineageweave/tepp_client.py index 7dbfd886f..70469327a 100644 --- a/lineageweave/tepp_client.py +++ b/lineageweave/tepp_client.py @@ -18,8 +18,9 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable +from typing import Any class TeppNotAvailable(RuntimeError): @@ -80,4 +81,9 @@ def __init__(self, transport: Callable[[dict[str, Any]], dict[str, Any]] = _no_t def submit_analysis_run(self, request: AnalysisRunRequest) -> dict[str, Any]: """Submit a request; returns TEPP's ``AnalysisRunAccepted`` envelope.""" - return self._transport(request.to_json()) + try: + return self._transport(request.to_json()) + except TeppNotAvailable: + raise + except Exception as exc: + raise TeppNotAvailable("TEPP transport request failed") from exc diff --git a/lineageweave/tepp_project_history.py b/lineageweave/tepp_project_history.py new file mode 100644 index 000000000..03ae94552 --- /dev/null +++ b/lineageweave/tepp_project_history.py @@ -0,0 +1,450 @@ +"""Strict client for TEPP's cutoff-safe project-history projection. + +LineageWeave owns authorization, exact project identity, and source selection. +TEPP may validate ordering and return temporal-association findings over that +closed evidence bundle. This module never forwards browser credentials, never +accepts changed source evidence, and never promotes order to causality. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from datetime import datetime, timezone +import json +import re +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +from .http_client import HttpClientError, post_json + +PROJECT_HISTORY_CONTRACT_VERSION = 1 +PROJECT_HISTORY_PATH = "/v1/project-histories" +PROJECT_HISTORY_INFERENCE_STATUS = "temporal_association_only" +PROJECT_HISTORY_EVENT_LIMIT = 128 +PROJECT_HISTORY_ACTOR_LIMIT = 64 +PROJECT_HISTORY_BYTE_LIMIT = 256 * 1024 +_RFC3339_PATTERN = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:" + r"[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:[Zz]|[+-][0-9]{2}:[0-9]{2})$" +) + +_REQUEST_FIELDS = frozenset( + { + "contract_version", + "idempotency_key", + "tenant_workspace_id", + "project_key", + "project_name", + "knowledge_cutoff", + "focus_event_id", + "events", + } +) +_EVENT_FIELDS = frozenset( + { + "event_id", + "event_type_code", + "event_title", + "occurred_at", + "available_at", + "source_post_id", + "evidence_text", + "actor_ids", + } +) +_PROJECTION_FIELDS = frozenset( + { + "contract_version", + "project_key", + "project_name", + "focus_event_id", + "knowledge_cutoff", + "history_span_start", + "history_span_end", + "participant_count", + "inference_status", + "events", + "findings", + } +) +_FINDING_FIELDS = frozenset( + {"finding_code", "summary", "related_event_ids", "evidence_post_ids"} +) +_ALLOWED_FINDING_CODES = frozenset( + { + "contract_award_before_focus", + "specification_change_before_focus", + "delivery_before_focus", + "handoff_before_focus", + "rebid_after_focus", + "specification_change_and_handoff_before_focus", + } +) + +Transport = Callable[[str, dict[str, Any], dict[str, str], float], Any] + + +class TeppProjectHistoryUnavailable(RuntimeError): + """TEPP was absent or returned a response outside the public contract.""" + + +class TeppProjectHistoryInvalidResponse(TeppProjectHistoryUnavailable): + """TEPP returned a response that violated the validated evidence contract.""" + + +def _exact_object(value: Any, fields: frozenset[str], name: str) -> Mapping[str, Any]: + """Return a mapping only when it has the exact versioned field set.""" + + if not isinstance(value, Mapping) or frozenset(value) != fields: + raise TeppProjectHistoryUnavailable(f"{name} has invalid fields") + return value + + +def _text(value: Any, name: str, maximum: int = 4096) -> str: + """Return bounded, non-empty text without ASCII control characters.""" + + if not isinstance(value, str): + raise TeppProjectHistoryUnavailable(f"{name} must be text") + normalized = value.strip() + if ( + not normalized + or len(normalized.encode("utf-8")) > maximum + or any(ord(character) < 0x20 or ord(character) == 0x7F for character in normalized) + ): + raise TeppProjectHistoryUnavailable(f"{name} is empty or outside its bound") + return normalized + + +def parse_rfc3339_utc(value: Any, name: str) -> tuple[datetime, str]: + """Parse an RFC 3339 timestamp and return canonical UTC text.""" + + raw = _text(value, name, 64) + if _RFC3339_PATTERN.fullmatch(raw) is None: + raise TeppProjectHistoryUnavailable(f"{name} is not RFC 3339") + normalized_text = raw[:10] + "T" + raw[11:] + try: + parsed = datetime.fromisoformat( + normalized_text[:-1] + "+00:00" + if normalized_text.endswith(("Z", "z")) + else normalized_text + ) + except ValueError as exc: + raise TeppProjectHistoryUnavailable(f"{name} is not RFC 3339") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise TeppProjectHistoryUnavailable(f"{name} must include an offset") + utc = parsed.astimezone(timezone.utc) + return utc, utc.isoformat().replace("+00:00", "Z") + + +def project_history_event_sort_key(event: Mapping[str, Any]) -> tuple[datetime, str]: + """Order project-history events by their instant, then stable identity.""" + + occurred_at, _ = parse_rfc3339_utc(event["occurred_at"], "occurred_at") + return occurred_at, str(event["event_id"]) + + +def _code(value: Any, name: str) -> str: + """Return a bounded lower-snake contract code.""" + + code = _text(value, name, 96) + if not all( + character.isascii() + and (character.islower() or character.isdigit() or character == "_") + for character in code + ): + raise TeppProjectHistoryUnavailable(f"{name} must be lower snake case") + return code + + +def _event(value: Any, *, cutoff: datetime | None = None) -> dict[str, Any]: + """Validate one exact source-grounded event.""" + + payload = _exact_object(value, _EVENT_FIELDS, "project-history event") + occurred, occurred_text = parse_rfc3339_utc(payload["occurred_at"], "occurred_at") + available, available_text = parse_rfc3339_utc(payload["available_at"], "available_at") + if cutoff is not None and (occurred > cutoff or available > cutoff): + raise TeppProjectHistoryUnavailable("event exceeds the knowledge cutoff") + raw_actors = payload["actor_ids"] + if not isinstance(raw_actors, list) or len(raw_actors) > PROJECT_HISTORY_ACTOR_LIMIT: + raise TeppProjectHistoryUnavailable("actor_ids must be a bounded list") + actors = [_text(actor, "actor_id", 256) for actor in raw_actors] + if len(actors) != len(set(actors)): + raise TeppProjectHistoryUnavailable("actor_ids must be unique within an event") + return { + "event_id": _text(payload["event_id"], "event_id", 256), + "event_type_code": _code(payload["event_type_code"], "event_type_code"), + "event_title": _text(payload["event_title"], "event_title", 512), + "occurred_at": occurred_text, + "available_at": available_text, + "source_post_id": _text(payload["source_post_id"], "source_post_id", 256), + "evidence_text": _text(payload["evidence_text"], "evidence_text", 4096), + "actor_ids": actors, + } + + +def validate_tepp_project_history_request( + value: Any, + *, + now: datetime | None = None, +) -> dict[str, Any]: + """Validate and canonicalize one TEPP project-history request.""" + + payload = _exact_object(value, _REQUEST_FIELDS, "project-history request") + if payload["contract_version"] != PROJECT_HISTORY_CONTRACT_VERSION: + raise TeppProjectHistoryUnavailable("unsupported request contract version") + receipt = now or datetime.now(timezone.utc) + if receipt.tzinfo is None or receipt.utcoffset() is None: + raise TeppProjectHistoryUnavailable("request receipt clock must be offset-aware") + cutoff, cutoff_text = parse_rfc3339_utc(payload["knowledge_cutoff"], "knowledge_cutoff") + if cutoff > receipt.astimezone(timezone.utc): + raise TeppProjectHistoryUnavailable("knowledge cutoff is after request receipt") + raw_events = payload["events"] + if ( + not isinstance(raw_events, list) + or not raw_events + or len(raw_events) > PROJECT_HISTORY_EVENT_LIMIT + ): + raise TeppProjectHistoryUnavailable("event count is outside the contract bound") + events = [_event(event, cutoff=cutoff) for event in raw_events] + event_ids = [event["event_id"] for event in events] + if len(event_ids) != len(set(event_ids)): + raise TeppProjectHistoryUnavailable("event identities must be unique") + focus_event_id = _text(payload["focus_event_id"], "focus_event_id", 256) + if focus_event_id not in set(event_ids): + raise TeppProjectHistoryUnavailable("focus event is outside the evidence bundle") + validated = { + "contract_version": PROJECT_HISTORY_CONTRACT_VERSION, + "idempotency_key": _text(payload["idempotency_key"], "idempotency_key", 256), + "tenant_workspace_id": _text( + payload["tenant_workspace_id"], "tenant_workspace_id", 256 + ), + "project_key": _text(payload["project_key"], "project_key", 256), + "project_name": _text(payload["project_name"], "project_name", 512), + "knowledge_cutoff": cutoff_text, + "focus_event_id": focus_event_id, + "events": events, + } + wire = json.dumps(validated, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + if len(wire) > PROJECT_HISTORY_BYTE_LIMIT: + raise TeppProjectHistoryUnavailable("project-history request exceeds 256 KiB") + return validated + + +def _finding( + value: Any, + *, + event_ids: set[str], + source_post_ids: set[str], +) -> dict[str, Any]: + """Validate one finding against the submitted evidence bundle.""" + + payload = _exact_object(value, _FINDING_FIELDS, "project-history finding") + related = payload["related_event_ids"] + evidence = payload["evidence_post_ids"] + if not isinstance(related, list) or not isinstance(evidence, list): + raise TeppProjectHistoryUnavailable("finding references must be lists") + related_ids = [_text(item, "related_event_id", 256) for item in related] + evidence_ids = [_text(item, "evidence_post_id", 256) for item in evidence] + if ( + not related_ids + or not evidence_ids + or not set(related_ids).issubset(event_ids) + or not set(evidence_ids).issubset(source_post_ids) + ): + raise TeppProjectHistoryUnavailable("finding cites evidence outside the bundle") + finding_code = _code(payload["finding_code"], "finding_code") + if finding_code not in _ALLOWED_FINDING_CODES: + raise TeppProjectHistoryUnavailable("finding code is not in the published vocabulary") + if len(related_ids) != len(set(related_ids)) or len(evidence_ids) != len( + set(evidence_ids) + ): + raise TeppProjectHistoryUnavailable("finding references must be unique") + return { + "finding_code": finding_code, + "summary": _text(payload["summary"], "finding summary", 4096), + "related_event_ids": related_ids, + "evidence_post_ids": evidence_ids, + } + + +def validate_tepp_project_history_projection( + value: Any, + *, + request: Any, +) -> dict[str, Any]: + """Validate TEPP output against the exact submitted events and identities.""" + + validated_request = validate_tepp_project_history_request(request) + payload = _exact_object(value, _PROJECTION_FIELDS, "project-history projection") + if payload["contract_version"] != PROJECT_HISTORY_CONTRACT_VERSION: + raise TeppProjectHistoryUnavailable("unsupported response contract version") + if payload["inference_status"] != PROJECT_HISTORY_INFERENCE_STATUS: + raise TeppProjectHistoryUnavailable("TEPP response attempted causal authority") + if ( + _text(payload["project_key"], "project_key", 256) + != validated_request["project_key"] + or _text(payload["project_name"], "project_name", 512) + != validated_request["project_name"] + or _text(payload["focus_event_id"], "focus_event_id", 256) + != validated_request["focus_event_id"] + ): + raise TeppProjectHistoryUnavailable("TEPP changed project or focus identity") + _, response_cutoff = parse_rfc3339_utc(payload["knowledge_cutoff"], "knowledge_cutoff") + if response_cutoff != validated_request["knowledge_cutoff"]: + raise TeppProjectHistoryUnavailable("TEPP changed the knowledge cutoff") + raw_events = payload["events"] + if not isinstance(raw_events, list): + raise TeppProjectHistoryUnavailable("projection events must be a list") + response_events = [_event(event) for event in raw_events] + expected_events = sorted( + validated_request["events"], + key=project_history_event_sort_key, + ) + if response_events != expected_events: + raise TeppProjectHistoryUnavailable("TEPP changed or reordered supplied evidence") + participant_count = payload["participant_count"] + expected_participants = len( + {actor for event in response_events for actor in event["actor_ids"]} + ) + if ( + isinstance(participant_count, bool) + or not isinstance(participant_count, int) + or participant_count != expected_participants + ): + raise TeppProjectHistoryUnavailable("participant count is not evidence-derived") + _, span_start = parse_rfc3339_utc(payload["history_span_start"], "history_span_start") + _, span_end = parse_rfc3339_utc(payload["history_span_end"], "history_span_end") + if ( + span_start != response_events[0]["occurred_at"] + or span_end != response_events[-1]["occurred_at"] + ): + raise TeppProjectHistoryUnavailable("history span does not match ordered events") + raw_findings = payload["findings"] + if not isinstance(raw_findings, list): + raise TeppProjectHistoryUnavailable("projection findings must be a list") + event_ids = {event["event_id"] for event in response_events} + source_post_ids = {event["source_post_id"] for event in response_events} + findings = [ + _finding( + finding, + event_ids=event_ids, + source_post_ids=source_post_ids, + ) + for finding in raw_findings + ] + return { + "contract_version": PROJECT_HISTORY_CONTRACT_VERSION, + "project_key": validated_request["project_key"], + "project_name": validated_request["project_name"], + "focus_event_id": validated_request["focus_event_id"], + "knowledge_cutoff": response_cutoff, + "history_span_start": span_start, + "history_span_end": span_end, + "participant_count": participant_count, + "inference_status": PROJECT_HISTORY_INFERENCE_STATUS, + "events": response_events, + "findings": findings, + } + + +def tepp_project_history_endpoint(transport_url: str) -> str: + """Resolve the project-history URL, allowing plain HTTP only on loopback.""" + + candidate = transport_url.strip() + if not candidate or any(ord(character) < 0x20 for character in candidate): + raise TeppProjectHistoryUnavailable("TEPP project-history transport is not configured") + parsed = urlsplit(candidate) + hostname = parsed.hostname.casefold() if parsed.hostname else "" + loopback = hostname in {"localhost", "127.0.0.1", "::1"} + if ( + not hostname + or (parsed.scheme != "https" and not (parsed.scheme == "http" and loopback)) + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise TeppProjectHistoryUnavailable("TEPP URL must be HTTPS or loopback HTTP") + try: + parsed.port + except ValueError as exc: + raise TeppProjectHistoryUnavailable("TEPP URL has an invalid port") from exc + path = parsed.path.rstrip("/") + if path.endswith("/v1/analysis-runs"): + path = path[: -len("/v1/analysis-runs")] + elif path.endswith(PROJECT_HISTORY_PATH): + path = path[: -len(PROJECT_HISTORY_PATH)] + elif path not in {"", "/"}: + raise TeppProjectHistoryUnavailable("TEPP URL has an unsupported path") + return urlunsplit( + (parsed.scheme, parsed.netloc, f"{path}{PROJECT_HISTORY_PATH}", "", "") + ) + + +class TeppProjectHistoryClient: + """Submit a credential-free request and validate TEPP's exact response.""" + + def __init__( + self, + transport_url: str, + *, + transport: Transport | None = None, + timeout_seconds: float = 30.0, + ) -> None: + self._transport_url = transport_url + self._transport = transport or self._post + self._timeout_seconds = timeout_seconds + + @property + def available(self) -> bool: + """Return whether a syntactically valid endpoint is configured.""" + + try: + tepp_project_history_endpoint(self._transport_url) + except TeppProjectHistoryUnavailable: + return False + return True + + @staticmethod + def _post( + url: str, + payload: dict[str, Any], + headers: dict[str, str], + timeout: float, + ) -> Any: + """Post one bounded JSON exchange through the shared HTTP client.""" + + return post_json( + url, + payload, + headers=headers, + timeout=timeout, + include_llm_metadata=False, + maximum_response_bytes=PROJECT_HISTORY_BYTE_LIMIT, + ) + + def project(self, request: Any) -> dict[str, Any]: + """Return a validated non-causal projection or fail closed.""" + + target = tepp_project_history_endpoint(self._transport_url) + payload = validate_tepp_project_history_request(request) + headers = { + "content-type": "application/json", + "tepp-consumer": "lineageweave", + "tepp-contract-version": str(PROJECT_HISTORY_CONTRACT_VERSION), + "idempotency-key": payload["idempotency_key"], + } + try: + response = self._transport(target, payload, headers, self._timeout_seconds) + except TeppProjectHistoryUnavailable: + raise + except (HttpClientError, OSError, TypeError, ValueError) as exc: + raise TeppProjectHistoryUnavailable("TEPP project-history request failed") from exc + except Exception as exc: + raise TeppProjectHistoryUnavailable("TEPP project-history request failed") from exc + try: + return validate_tepp_project_history_projection(response, request=payload) + except TeppProjectHistoryUnavailable as exc: + raise TeppProjectHistoryInvalidResponse( + "TEPP project-history response violated its contract" + ) from exc diff --git a/migrations/0054_post_chat_knowledge_cutoff.sql b/migrations/0054_post_chat_knowledge_cutoff.sql new file mode 100644 index 000000000..00d05706d --- /dev/null +++ b/migrations/0054_post_chat_knowledge_cutoff.sql @@ -0,0 +1,28 @@ +alter table post_chat_result + add column if not exists knowledge_cutoff timestamptz; + +update post_chat_result + set knowledge_cutoff = computed_at + where knowledge_cutoff is null; + +alter table post_chat_result + alter column knowledge_cutoff set default now(), + alter column knowledge_cutoff set not null; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'post_chat_result_knowledge_cutoff_check' + and conrelid = 'post_chat_result'::regclass + ) then + alter table post_chat_result + add constraint post_chat_result_knowledge_cutoff_check + check (knowledge_cutoff <= computed_at); + end if; +end +$$; + +comment on column post_chat_result.knowledge_cutoff is + 'Maximum source availability time used to compute this persisted answer.'; diff --git a/migrations/rollback/0054_post_chat_knowledge_cutoff.sql b/migrations/rollback/0054_post_chat_knowledge_cutoff.sql new file mode 100644 index 000000000..8980fe69f --- /dev/null +++ b/migrations/rollback/0054_post_chat_knowledge_cutoff.sql @@ -0,0 +1,5 @@ +alter table post_chat_result + drop constraint if exists post_chat_result_knowledge_cutoff_check; + +alter table post_chat_result + drop column if exists knowledge_cutoff; diff --git a/pyproject.toml b/pyproject.toml index 5f00a4062..8d34399ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.18.0" +version = "2.20.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_ask_project_history.py b/tests/test_ask_project_history.py new file mode 100644 index 000000000..c9420be90 --- /dev/null +++ b/tests/test_ask_project_history.py @@ -0,0 +1,348 @@ +"""Contracts for project histories attached to post-scoped and Global Ask.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from backend.app import main +from backend.app.ask_project_history import ( + AskEvidenceProjection, + global_ask_session_citations_authorized, + read_authorized_ask_evidence, +) +from backend.app.auth import CurrentAccount +from backend.app.post_chat_ingestion import gather_global_chat_sources + +CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=UTC) + + +class _EvidenceConnection: + """Query-shaped double for current citation and project evidence.""" + + def __init__(self, rows: list[dict[str, object]]) -> None: + self.rows = rows + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + async def fetch(self, query: str, *args: object): + self.calls.append((query, args)) + return self.rows + + +def test_authorized_ask_evidence_groups_exact_projects_and_preserves_citation_order() -> None: + conn = _EvidenceConnection( + [ + { + "post_id": "00000000-0000-4000-8000-000000000002", + "post_title": "Second evidence", + "citation_ordinal": 2, + "project_key": "P-100", + "project_name": "Synthetic renewal", + "truth_status_code": "inferred", + "truth_order": 1, + }, + { + "post_id": "00000000-0000-4000-8000-000000000001", + "post_title": "First evidence", + "citation_ordinal": 1, + "project_key": "P-100", + "project_name": "Synthetic renewal", + "truth_status_code": "observed", + "truth_order": 0, + }, + ] + ) + + result = asyncio.run( + read_authorized_ask_evidence( + conn, + cited_post_ids=[ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + ], + corporate_entity_ids=["tenant-a"], + knowledge_cutoff=CUTOFF, + ) + ) + + assert result.all_citations_visible + assert [post["post_title"] for post in result.cited_posts] == [ + "First evidence", + "Second evidence", + ] + assert result.project_histories == ( + { + "project_key": "P-100", + "project_name": "Synthetic renewal", + "focus_post_id": "00000000-0000-4000-8000-000000000001", + "source_post_ids": [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + ], + "knowledge_cutoff": "2026-08-20T12:00:00Z", + "truth_status_code": "observed", + }, + ) + query, args = conn.calls[0] + assert "source_draft_code" in query + assert "source_deleted_flag" in query + assert "created_at <= $3" in query + assert args[2] == CUTOFF + + +def test_authorized_ask_evidence_fails_closed_when_any_citation_is_hidden() -> None: + conn = _EvidenceConnection( + [ + { + "post_id": "00000000-0000-4000-8000-000000000001", + "post_title": "Visible evidence", + "citation_ordinal": 1, + "project_key": None, + "project_name": None, + "truth_status_code": None, + "truth_order": None, + } + ] + ) + + result = asyncio.run( + read_authorized_ask_evidence( + conn, + cited_post_ids=[ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000099", + ], + corporate_entity_ids=["tenant-a"], + knowledge_cutoff=CUTOFF, + ) + ) + + assert not result.all_citations_visible + assert result.project_histories == () + + +def test_authorized_ask_evidence_rejects_non_uuid_citations_before_sql() -> None: + with pytest.raises(ValueError, match="UUIDs"): + asyncio.run( + read_authorized_ask_evidence( + _EvidenceConnection([]), + cited_post_ids=["not-a-uuid"], + corporate_entity_ids=["tenant-a"], + knowledge_cutoff=CUTOFF, + ) + ) + + +def test_global_ask_session_reauthorizes_every_persisted_citation() -> None: + class SessionConnection: + def __init__(self) -> None: + self.call = 0 + + async def fetch(self, query: str, *args: object): + del args + self.call += 1 + if "global_ask_turn_citation" in query: + return [ + {"cited_post_id": "00000000-0000-4000-8000-000000000001"}, + {"cited_post_id": "00000000-0000-4000-8000-000000000099"}, + ] + return [ + { + "post_id": "00000000-0000-4000-8000-000000000001", + "post_title": "Visible evidence", + "citation_ordinal": 1, + "project_key": None, + "project_name": None, + "truth_status_code": None, + "truth_order": None, + } + ] + + authorized = asyncio.run( + global_ask_session_citations_authorized( + SessionConnection(), + session_id="00000000-0000-4000-8000-000000000010", + corporate_entity_ids=["tenant-a"], + knowledge_cutoff=CUTOFF, + ) + ) + assert not authorized + + +def test_global_source_retrieval_applies_cutoff_and_publication_eligibility() -> None: + calls: list[tuple[str, tuple[object, ...]]] = [] + + class CaptureConnection: + async def fetch(self, query: str, *args: object): + calls.append((query, args)) + return [] + + asyncio.run( + gather_global_chat_sources( + CaptureConnection(), + lambda _row: True, + ["tenant-a"], + question="synthetic project", + limit=2, + knowledge_cutoff=CUTOFF, + ) + ) + + candidate_queries = [query for query, _args in calls if "matched_in" in query] + source_calls = [ + (query, args) + for query, args in calls + if "array_position($2::uuid[], post_id)" in query + ] + assert candidate_queries + assert all("source_draft_code" in query and "created_at <= $3" in query for query in candidate_queries) + assert source_calls + source_query, source_args = source_calls[0] + assert "source_deleted_flag" in source_query + assert "created_at <= $4" in source_query + assert source_args[3] == CUTOFF + + +class _Acquire: + def __init__(self, connection: object) -> None: + self.connection = connection + + async def __aenter__(self) -> object: + return self.connection + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + return None + + +class _Pool: + def __init__(self, connection: object) -> None: + self.connection = connection + + def acquire(self) -> _Acquire: + return _Acquire(self.connection) + + +def _account() -> CurrentAccount: + return CurrentAccount( + user_account_id="account-1", + external_subject_id="subject-1", + display_name="Synthetic analyst", + preferred_locale="en", + corporate_entity_ids=frozenset({"tenant-a"}), + permission_codes=frozenset({"post_read"}), + ) + + +def test_stored_post_chat_omits_an_answer_after_citation_access_is_lost(monkeypatch) -> None: + async def visible_post(*_args, **_kwargs): + return {"post_id": "post-1"} + + async def stored_chats(*_args, **_kwargs): + return [ + { + "question_text": "What happened?", + "answer_text": "A formerly authorized answer.", + "cited_post_ids": ["hidden-post"], + "cited_posts": [{"post_id": "hidden-post", "post_title": "Hidden"}], + "_knowledge_cutoff": CUTOFF, + } + ] + + async def hidden_evidence(*_args, **_kwargs): + return AskEvidenceProjection( + all_citations_visible=False, + cited_posts=(), + project_histories=(), + project_histories_truncated=False, + knowledge_cutoff="2026-08-20T12:00:00Z", + ) + + monkeypatch.setattr(main, "_load_visible_post", visible_post) + monkeypatch.setattr(main, "fetch_persisted_chats", stored_chats) + monkeypatch.setattr(main, "read_authorized_ask_evidence", hidden_evidence) + + result = asyncio.run( + main.read_post_chat( + post_id="post-1", + account=_account(), + pool=_Pool(object()), + ) + ) + assert result == {"post_id": "post-1", "exchanges": []} + + +def test_global_ask_rejects_stale_session_context_before_reusing_hidden_prose(monkeypatch) -> None: + async def ensure_session(*_args, **_kwargs): + return "00000000-0000-4000-8000-000000000010" + + async def unauthorized(*_args, **_kwargs): + return False + + monkeypatch.setattr(main, "_post_chat_client", lambda: SimpleNamespace(available=True)) + monkeypatch.setattr(main, "ensure_global_ask_session", ensure_session) + monkeypatch.setattr(main, "global_ask_session_citations_authorized", unauthorized) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + main.ask_agent( + request=main.GlobalAskRequest( + question="Continue the prior answer", + session_id="00000000-0000-4000-8000-000000000010", + ), + account=_account(), + pool=_Pool(object()), + valkey=SimpleNamespace(), + ) + ) + + assert exc_info.value.status_code == 409 + assert "start a new session" in str(exc_info.value.detail).lower() + + +def test_global_ask_hides_unexpected_provider_errors(monkeypatch) -> None: + class ProviderFailure: + available = True + + def answer(self, *args, **kwargs): + del args, kwargs + raise RuntimeError("raw provider trace must not reach the buyer") + + async def ensure_session(*_args, **_kwargs): + return "00000000-0000-4000-8000-000000000010" + + async def authorized(*_args, **_kwargs): + return True + + async def load_context(*_args, **_kwargs): + return SimpleNamespace( + session_id="00000000-0000-4000-8000-000000000010", + summary="", + recent_turns=(), + compress_turns=(), + ) + + async def sources(*_args, **_kwargs): + return [object()] + + monkeypatch.setattr(main, "_post_chat_client", lambda: ProviderFailure()) + monkeypatch.setattr(main, "ensure_global_ask_session", ensure_session) + monkeypatch.setattr(main, "global_ask_session_citations_authorized", authorized) + monkeypatch.setattr(main, "load_global_ask_context", load_context) + monkeypatch.setattr(main, "gather_global_chat_sources", sources) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + main.ask_agent( + request=main.GlobalAskRequest(question="What happened?"), + account=_account(), + pool=_Pool(object()), + valkey=SimpleNamespace(), + ) + ) + + assert exc_info.value.status_code == 503 + assert "raw provider trace" not in str(exc_info.value.detail) diff --git a/tests/test_ask_project_history_cutoff.py b/tests/test_ask_project_history_cutoff.py new file mode 100644 index 000000000..22c7cd059 --- /dev/null +++ b/tests/test_ask_project_history_cutoff.py @@ -0,0 +1,77 @@ +"""Contracts for persisted post-Ask knowledge cutoffs.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from pathlib import Path + +from backend.app.post_chat_ingestion import persist_post_chat + + +ROOT = Path(__file__).resolve().parents[1] +CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=timezone.utc) + + +class _Connection: + """Minimal chat-persistence double that records SQL parameters.""" + + def __init__(self) -> None: + self.executions: list[tuple[str, tuple[object, ...]]] = [] + + async def execute(self, query: str, *args: object) -> None: + self.executions.append((query, args)) + + async def fetchrow(self, query: str, *args: object): + del args + if "from post_chat_result" not in query: + return None + return { + "question_text": "What happened?", + "answer_text": "Synthetic answer", + "knowledge_cutoff": CUTOFF, + } + + async def fetch(self, query: str, *args: object): + del query, args + return [] + + +def test_persist_post_chat_writes_the_retrieval_cutoff_not_a_later_read_clock() -> None: + conn = _Connection() + + result = asyncio.run( + persist_post_chat( + conn, + "00000000-0000-4000-8000-000000000001", + "What happened?", + "Synthetic answer", + [], + knowledge_cutoff=CUTOFF, + ) + ) + + insert = next( + (query, args) + for query, args in conn.executions + if "insert into post_chat_result" in query + ) + assert "computed_at" in insert[0] + assert "knowledge_cutoff" in insert[0] + assert insert[1][-2] >= CUTOFF + assert insert[1][-1] == CUTOFF + assert result["_knowledge_cutoff"] == CUTOFF + + +def test_cutoff_migration_is_applied_and_fails_closed_on_inverted_clocks() -> None: + migration = ROOT / "migrations/0054_post_chat_knowledge_cutoff.sql" + rollback = ROOT / "migrations/rollback/0054_post_chat_knowledge_cutoff.sql" + migrate_script = (ROOT / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") + + assert migration.is_file() + text = migration.read_text(encoding="utf-8") + assert "knowledge_cutoff timestamptz" in text + assert "knowledge_cutoff = computed_at" in text + assert "knowledge_cutoff <= computed_at" in text + assert rollback.is_file() + assert "0054_*" in migrate_script diff --git a/tests/test_global_ask_cutoff_contract.py b/tests/test_global_ask_cutoff_contract.py new file mode 100644 index 000000000..bdcf082ad --- /dev/null +++ b/tests/test_global_ask_cutoff_contract.py @@ -0,0 +1,54 @@ +"""Regression contracts for the final Global Ask source query.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime + +from backend.app.post_chat_ingestion import gather_global_chat_sources + + +CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=UTC) +AUTHORIZED_ENTITY_ID = "00000000-0000-4000-8000-000000000001" + + +class _RecordingConnection: + """Record query arguments while returning an empty authorized corpus.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + async def fetch(self, query: str, *args: object): + """Record one query call and return no rows.""" + + self.calls.append((query, args)) + return [] + + +def test_final_global_source_query_reuses_scope_and_binds_cutoff() -> None: + """One-shot tenant scope and the cutoff survive into the final SQL call.""" + + connection = _RecordingConnection() + authorized_ids = (value for value in [AUTHORIZED_ENTITY_ID]) + + result = asyncio.run( + gather_global_chat_sources( + connection, + lambda _row: True, + authorized_ids, + question="", + limit=2, + knowledge_cutoff=CUTOFF, + ) + ) + + assert result == [] + final_calls = [ + (query, args) + for query, args in connection.calls + if "array_position($2::uuid[], post_id)" in query + ] + assert len(final_calls) == 1 + final_query, final_args = final_calls[0] + assert "created_at <= $4" in final_query + assert final_args == ([AUTHORIZED_ENTITY_ID], [], 2, CUTOFF) diff --git a/tests/test_global_ask_cutoff_postgres.py b/tests/test_global_ask_cutoff_postgres.py new file mode 100644 index 000000000..5d07797a8 --- /dev/null +++ b/tests/test_global_ask_cutoff_postgres.py @@ -0,0 +1,82 @@ +"""PostgreSQL regression for the final Global Ask cutoff boundary.""" + +from __future__ import annotations + +import asyncio +import os +from datetime import UTC, datetime + +import asyncpg +import pytest + +from backend.app.post_chat_ingestion import gather_global_chat_sources + + +CUTOFF = datetime(2026, 8, 20, 12, 0, tzinfo=UTC) +POSTGRES_DSN = os.environ.get("LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN") + + +@pytest.mark.skipif(not POSTGRES_DSN, reason="requires PostgreSQL integration DSN") +def test_final_global_source_query_binds_the_cutoff_in_real_postgresql() -> None: + """The final authorized-source SQL binds every positional parameter.""" + + async def scenario() -> None: + connection = await asyncpg.connect(POSTGRES_DSN) + try: + await connection.execute( + """ + create temporary table source_post ( + post_id uuid primary key, + post_title text, + post_body text, + visibility_code text, + corporate_entity_id uuid, + created_at timestamptz, + source_system_code text, + source_record_key text, + source_author_code text, + source_author_name text, + source_company_code text, + source_company_name text, + source_process_unit_code text, + source_process_unit_name text, + source_sales_pool_code text, + source_sales_pool_name text, + source_customer_code text, + source_customer_name text, + source_project_code text, + source_project_name text, + source_draft_code text, + source_deleted_flag text + ) + """ + ) + + class PostgresBoundary: + """Execute only the final source query against PostgreSQL.""" + + def __init__(self) -> None: + self.final_args: tuple[object, ...] | None = None + + async def fetch(self, query: str, *args: object): + if "array_position($2::uuid[], post_id)" in query: + self.final_args = args + return await connection.fetch(query, *args) + return [] + + boundary = PostgresBoundary() + result = await gather_global_chat_sources( + boundary, + lambda _row: True, + ["00000000-0000-4000-8000-000000000001"], + question="synthetic project", + limit=2, + knowledge_cutoff=CUTOFF, + ) + assert result == [] + assert boundary.final_args is not None + assert boundary.final_args[3] == CUTOFF + finally: + await connection.close() + + asyncio.run(scenario()) diff --git a/tests/test_migration_identity.py b/tests/test_migration_identity.py new file mode 100644 index 000000000..3e5c19a92 --- /dev/null +++ b/tests/test_migration_identity.py @@ -0,0 +1,30 @@ +"""Migration identity and replay-window contracts.""" + +from __future__ import annotations + +from collections import Counter +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_forward_migration_numeric_prefixes_are_unique() -> None: + """Every forward migration has one unambiguous numeric identity.""" + + migrations = sorted((ROOT / "migrations").glob("[0-9][0-9][0-9][0-9]_*.sql")) + counts = Counter(path.name.split("_", 1)[0] for path in migrations) + duplicates = sorted(prefix for prefix, count in counts.items() if count > 1) + assert duplicates == [] + + +def test_post_chat_cutoff_uses_the_next_unique_replayable_migration() -> None: + """The Ask cutoff migration remains independently addressable and replayed.""" + + forward = ROOT / "migrations/0054_post_chat_knowledge_cutoff.sql" + rollback = ROOT / "migrations/rollback/0054_post_chat_knowledge_cutoff.sql" + script = (ROOT / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") + assert forward.is_file() + assert rollback.is_file() + assert not (ROOT / "migrations/0053_post_chat_knowledge_cutoff.sql").exists() + assert "0054_*" in script diff --git a/tests/test_strict_http_metadata.py b/tests/test_strict_http_metadata.py new file mode 100644 index 000000000..85e87d8b3 --- /dev/null +++ b/tests/test_strict_http_metadata.py @@ -0,0 +1,205 @@ +"""Contracts for contextual metadata on open and closed HTTP payloads.""" + +from __future__ import annotations + +import json +import threading +from copy import deepcopy +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from lineageweave import tepp_project_history as tepp_transport_module +from lineageweave.http_client import HttpClientError, post_json +from lineageweave.llm_context import use_llm_metadata +from lineageweave.tepp_project_history import ( + TeppProjectHistoryClient, + TeppProjectHistoryUnavailable, + validate_tepp_project_history_request, +) + + +class _EchoHandler(BaseHTTPRequestHandler): + """Echo one JSON request for shared-client contract tests.""" + + def do_POST(self) -> None: # noqa: N802 -- stdlib callback name + length = int(self.headers.get("content-length", "0")) + payload = json.loads(self.rfile.read(length).decode("utf-8")) + body = json.dumps( + {"oversized": "x" * 512} if self.path == "/oversized" else {"echo": payload} + ).encode("utf-8") + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + """Suppress test HTTP access logs.""" + + +def _serve() -> tuple[HTTPServer, str]: + """Start one local JSON echo server.""" + + server = HTTPServer(("127.0.0.1", 0), _EchoHandler) + threading.Thread(target=server.serve_forever, daemon=True).start() + host, port = server.server_address[:2] + return server, f"http://{host}:{port}" + + +def _request() -> dict[str, object]: + """Return one minimal exact TEPP project-history request.""" + + return { + "contract_version": 1, + "idempotency_key": "strict-http-metadata", + "tenant_workspace_id": "tenant-a", + "project_key": "P-100", + "project_name": "Synthetic renewal", + "knowledge_cutoff": "2026-08-20T12:00:00Z", + "focus_event_id": "event-1", + "events": [ + { + "event_id": "event-1", + "event_type_code": "voc_received", + "event_title": "Synthetic VOC received", + "occurred_at": "2026-08-20T10:00:00Z", + "available_at": "2026-08-20T10:00:00Z", + "source_post_id": "post-1", + "evidence_text": "Synthetic VOC received", + "actor_ids": ["lw-actor-1"], + } + ], + } + + +def _response(request: dict[str, object]) -> dict[str, object]: + """Return the exact successful response for ``request``.""" + + events = deepcopy(request["events"]) + return { + "contract_version": 1, + "project_key": request["project_key"], + "project_name": request["project_name"], + "focus_event_id": request["focus_event_id"], + "knowledge_cutoff": request["knowledge_cutoff"], + "history_span_start": events[0]["occurred_at"], + "history_span_end": events[-1]["occurred_at"], + "participant_count": 1, + "inference_status": "temporal_association_only", + "events": events, + "findings": [], + } + + +def test_post_json_includes_llm_metadata_by_default() -> None: + """Existing LLM clients retain contextual metadata enrichment.""" + + server, base = _serve() + try: + with use_llm_metadata({"lineageweave_post_id": "post-1"}): + body = post_json( + f"{base}/v1/chat/completions", + {"messages": []}, + headers={}, + timeout=2.0, + ) + finally: + server.shutdown() + + assert body["echo"] == { + "messages": [], + "metadata": {"lineageweave_post_id": "post-1"}, + } + + +def test_post_json_can_disable_metadata_for_a_closed_contract() -> None: + """Closed contracts remain byte-shape compatible inside an LLM context.""" + + server, base = _serve() + try: + with use_llm_metadata({"lineageweave_post_id": "post-1"}): + body = post_json( + f"{base}/v1/project-histories", + {"contract_version": 1}, + headers={}, + timeout=2.0, + include_llm_metadata=False, + ) + finally: + server.shutdown() + + assert body["echo"] == {"contract_version": 1} + + +def test_post_json_rejects_a_response_above_the_contract_byte_limit() -> None: + """A bounded wire contract never buffers an oversized remote response.""" + + server, base = _serve() + try: + with pytest.raises(HttpClientError, match="response exceeds"): + post_json( + f"{base}/oversized", + {}, + headers={}, + timeout=2.0, + maximum_response_bytes=256, + ) + finally: + server.shutdown() + + +def test_tepp_request_rejects_payload_above_the_published_byte_limit() -> None: + """LineageWeave rejects oversized evidence before TEPP returns HTTP 400.""" + + request = _request() + template = request["events"][0] + request["events"] = [ + { + **template, + "event_id": f"event-{index}", + "source_post_id": f"post-{index}", + "evidence_text": "x" * 4096, + } + for index in range(128) + ] + request["focus_event_id"] = "event-0" + + with pytest.raises(TeppProjectHistoryUnavailable, match="request exceeds"): + validate_tepp_project_history_request(request) + + +def test_default_tepp_transport_disables_context_metadata(monkeypatch) -> None: + """The strict TEPP adapter opts out even when Ask sets LLM metadata.""" + + request = _request() + captured: dict[str, object] = {} + + def fake_post_json( + url, + payload, + *, + headers, + timeout, + include_llm_metadata, + maximum_response_bytes, + ): + captured.update( + url=url, + payload=deepcopy(payload), + headers=headers, + timeout=timeout, + include_llm_metadata=include_llm_metadata, + maximum_response_bytes=maximum_response_bytes, + ) + return _response(payload) + + monkeypatch.setattr(tepp_transport_module, "post_json", fake_post_json) + with use_llm_metadata({"lineageweave_post_id": "must-not-cross"}): + result = TeppProjectHistoryClient("https://tepp.example").project(request) + + assert result["inference_status"] == "temporal_association_only" + assert captured["include_llm_metadata"] is False + assert captured["maximum_response_bytes"] == 256 * 1024 + assert captured["payload"] == request + assert "metadata" not in captured["payload"] diff --git a/tests/test_tepp_client.py b/tests/test_tepp_client.py index ea87d5558..dd79930c7 100644 --- a/tests/test_tepp_client.py +++ b/tests/test_tepp_client.py @@ -52,6 +52,18 @@ def fake_transport(payload: dict) -> dict: assert received["snapshot_id"] == "demo-snapshot-1" +def test_custom_transport_provider_errors_are_not_exposed() -> None: + """Provider response text stays behind the stable unavailable error.""" + + def broken_transport(_payload: dict) -> dict: + raise RuntimeError("provider secret response body") + + with pytest.raises(TeppNotAvailable, match="transport request failed") as error: + TeppClient(transport=broken_transport).submit_analysis_run(_sample_request()) + + assert "provider secret" not in str(error.value) + + def test_configured_transport_sends_optional_bearer_key(monkeypatch: pytest.MonkeyPatch) -> None: received = {} @@ -66,3 +78,22 @@ def fake_post_json(url: str, payload: dict, *, headers: dict, timeout: float) -> assert received["headers"] == {"authorization": "Bearer test-key"} assert received["payload"] == _sample_request().to_json() + + +def test_configured_transport_provider_errors_are_not_exposed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The configured provider boundary does not return raw transport text.""" + + def broken_post_json(*args, **kwargs): + del args, kwargs + raise RuntimeError("provider secret response body") + + monkeypatch.setattr("backend.app.analysis_run_start.post_json", broken_post_json) + + with pytest.raises(TeppNotAvailable, match="transport request failed") as error: + configured_tepp_client("https://tepp.example/v1/analysis-runs").submit_analysis_run( + _sample_request() + ) + + assert "provider secret" not in str(error.value) diff --git a/tests/test_tepp_project_history_recovery.py b/tests/test_tepp_project_history_recovery.py new file mode 100644 index 000000000..bb260517a --- /dev/null +++ b/tests/test_tepp_project_history_recovery.py @@ -0,0 +1,408 @@ +"""Regression contracts for the recovered TEPP project-history integration.""" + +from __future__ import annotations + +import asyncio +import json +from copy import deepcopy +from types import SimpleNamespace + +import pytest + +from backend.app import main +from backend.app.auth import CurrentAccount +from backend.app.tepp_project_history import ( + build_tepp_project_history_request, + tenant_workspace_reference, + validate_project_history_with_tepp, +) +from lineageweave.tepp_project_history import ( + TeppProjectHistoryClient, + TeppProjectHistoryInvalidResponse, + TeppProjectHistoryUnavailable, + parse_rfc3339_utc, +) + + +def _canonical_projection() -> dict[str, object]: + """Return one synthetic authorized LineageWeave project history.""" + + return { + "contract_version": 1, + "project_key": "P-100", + "normalized_project_key": "p-100", + "project_name": "Synthetic transformer renewal", + "focus_event_id": "00000000-0000-4000-8000-000000000003", + "time_basis_code": "source_post_created_at_fallback", + "knowledge_cutoff": "2026-08-20T12:00:00+00:00", + "evidence_boundary_code": "authorized_visible_source_posts", + "event_count": 3, + "distinct_actor_count": 2, + "distinct_observed_actor_count": 1, + "truncated": False, + "events": [ + { + "event_id": "00000000-0000-4000-8000-000000000001", + "source_post_id": "00000000-0000-4000-8000-000000000001", + "event_title": "Synthetic contract awarded", + "event_type_code": "contract_awarded", + "event_type_basis_code": "display_classification", + "occurred_at": "2022-03-11T09:00:00Z", + "time_basis_code": "source_post_created_at_fallback", + "voc_type_code": None, + "source_stage_code": "award", + "source_detail_state_code": None, + "project_matches": [], + "responsibility_evidence": [ + { + "actor_key": "text:prov_person\u001fsynthetic owner\u001fdemo org", + "actor_name": "Synthetic Owner", + "actor_type_code": "prov_person", + "affiliated_organization_name": "Demo Org", + "responsibility": "Source author", + "truth_status_code": "observed", + "provenance": "source_post.source_author", + } + ], + "observed_responsibilities": [], + "responsibility_transition_code": None, + "responsibility_transition_truth_status_code": None, + "related_prior_paths": [], + }, + { + "event_id": "00000000-0000-4000-8000-000000000002", + "source_post_id": "00000000-0000-4000-8000-000000000002", + "event_title": "Synthetic specification changed", + "event_type_code": "specification_changed", + "event_type_basis_code": "display_classification", + "occurred_at": "2023-06-15T09:00:00Z", + "time_basis_code": "source_post_created_at_fallback", + "voc_type_code": None, + "source_stage_code": "spec_change", + "source_detail_state_code": None, + "project_matches": [], + "responsibility_evidence": [ + { + "actor_key": "person:synthetic-pm", + "actor_name": "Synthetic PM", + "actor_type_code": "prov_person", + "affiliated_organization_name": "Demo Org", + "responsibility": "Coordinate change", + "truth_status_code": "inferred", + "provenance": "post_summary_role", + } + ], + "observed_responsibilities": [], + "responsibility_transition_code": "handoff", + "responsibility_transition_truth_status_code": "inferred", + "related_prior_paths": [], + }, + { + "event_id": "00000000-0000-4000-8000-000000000003", + "source_post_id": "00000000-0000-4000-8000-000000000003", + "event_title": "Synthetic VOC received", + "event_type_code": "voc_received", + "event_type_basis_code": "display_classification", + "occurred_at": "2026-02-02T09:00:00Z", + "time_basis_code": "source_post_created_at_fallback", + "voc_type_code": "voc", + "source_stage_code": None, + "source_detail_state_code": None, + "project_matches": [], + "responsibility_evidence": [], + "observed_responsibilities": [], + "responsibility_transition_code": "assignment_gap", + "responsibility_transition_truth_status_code": "inferred", + "related_prior_paths": [], + }, + ], + } + + +def _tepp_response(request: dict[str, object]) -> dict[str, object]: + """Return the exact TEPP #159 response shape for a validated request.""" + + events = sorted( + deepcopy(request["events"]), + key=lambda event: ( + parse_rfc3339_utc(event["occurred_at"], "occurred_at")[0], + event["event_id"], + ), + ) + actors = {actor for event in events for actor in event["actor_ids"]} + return { + "contract_version": 1, + "project_key": request["project_key"], + "project_name": request["project_name"], + "focus_event_id": request["focus_event_id"], + "knowledge_cutoff": request["knowledge_cutoff"], + "history_span_start": events[0]["occurred_at"], + "history_span_end": events[-1]["occurred_at"], + "participant_count": len(actors), + "inference_status": "temporal_association_only", + "events": events, + "findings": [ + { + "finding_code": "specification_change_before_focus", + "summary": "An explicit specification-change event precedes the focus event.", + "related_event_ids": [events[1]["event_id"]], + "evidence_post_ids": [events[1]["source_post_id"]], + } + ], + } + + +def test_mapper_uses_opaque_actor_references_and_bounded_source_evidence() -> None: + projection = _canonical_projection() + workspace = tenant_workspace_reference(["tenant-b", "tenant-a"]) + + request = build_tepp_project_history_request( + projection=projection, + tenant_workspace_id=workspace, + ) + encoded = json.dumps(request, ensure_ascii=False) + + assert workspace == tenant_workspace_reference(["tenant-a", "tenant-b"]) + assert "Synthetic Owner" not in encoded + assert "Synthetic PM" not in encoded + assert "Demo Org" not in encoded + assert all( + actor.startswith("lw-actor-") + for event in request["events"] + for actor in event["actor_ids"] + ) + assert request["events"][0]["available_at"] == request["events"][0]["occurred_at"] + assert request["events"][0]["evidence_text"].startswith("Synthetic contract awarded") + + +def test_mapper_and_tepp_validation_order_fractional_seconds_by_instant() -> None: + """Events in the same second retain chronological rather than text order.""" + + projection = _canonical_projection() + projection["events"][0]["occurred_at"] = "2022-03-11T09:00:00.500Z" + projection["events"][1]["occurred_at"] = "2022-03-11T09:00:00Z" + request = build_tepp_project_history_request( + projection=projection, + tenant_workspace_id=tenant_workspace_reference(["tenant-a"]), + ) + + assert [event["event_id"] for event in request["events"]] == [ + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000003", + ] + + client = TeppProjectHistoryClient( + "https://tepp.example", + transport=lambda url, payload, headers, timeout: _tepp_response(payload), + ) + result = client.project(request) + assert [event["event_id"] for event in result["events"]] == [ + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000003", + ] + + +@pytest.mark.parametrize("timestamp", ["2026-08-20 12:00:00Z", "2026-08-20T12:00:00+0900"]) +def test_mapper_rejects_non_rfc3339_timestamp_shapes(timestamp: str) -> None: + projection = _canonical_projection() + projection["knowledge_cutoff"] = timestamp + + with pytest.raises(TeppProjectHistoryUnavailable, match="RFC 3339"): + build_tepp_project_history_request( + projection=projection, + tenant_workspace_id=tenant_workspace_reference(["tenant-a"]), + ) + + +def test_strict_client_accepts_tepp_159_and_rejects_authority_or_evidence_drift() -> None: + request = build_tepp_project_history_request( + projection=_canonical_projection(), + tenant_workspace_id=tenant_workspace_reference(["tenant-a"]), + ) + captured: dict[str, object] = {} + + def transport(url, payload, headers, timeout): + captured.update(url=url, payload=payload, headers=headers, timeout=timeout) + return _tepp_response(payload) + + client = TeppProjectHistoryClient("https://tepp.example", transport=transport) + result = client.project(request) + + assert result["inference_status"] == "temporal_association_only" + assert captured["url"] == "https://tepp.example/v1/project-histories" + assert "authorization" not in {key.lower() for key in captured["headers"]} + assert captured["headers"]["tepp-consumer"] == "lineageweave" + + def causal_transport(url, payload, headers, timeout): + del url, headers, timeout + response = _tepp_response(payload) + response["inference_status"] = "causal" + return response + + with pytest.raises(TeppProjectHistoryInvalidResponse): + TeppProjectHistoryClient( + "https://tepp.example", transport=causal_transport + ).project(request) + + def changed_evidence_transport(url, payload, headers, timeout): + del url, headers, timeout + response = _tepp_response(payload) + response["events"][0]["evidence_text"] = "changed" + return response + + with pytest.raises(TeppProjectHistoryInvalidResponse): + TeppProjectHistoryClient( + "https://tepp.example", transport=changed_evidence_transport + ).project(request) + + +def test_strict_client_rejects_unknown_or_duplicate_finding_references() -> None: + request = build_tepp_project_history_request( + projection=_canonical_projection(), + tenant_workspace_id=tenant_workspace_reference(["tenant-a"]), + ) + + def unknown_finding_transport(url, payload, headers, timeout): + del url, headers, timeout + response = _tepp_response(payload) + response["findings"][0]["finding_code"] = "provider_authored_conclusion" + return response + + with pytest.raises(TeppProjectHistoryInvalidResponse): + TeppProjectHistoryClient( + "https://tepp.example", transport=unknown_finding_transport + ).project(request) + + def duplicate_reference_transport(url, payload, headers, timeout): + del url, headers, timeout + response = _tepp_response(payload) + event_id = response["findings"][0]["related_event_ids"][0] + response["findings"][0]["related_event_ids"] = [event_id, event_id] + return response + + with pytest.raises(TeppProjectHistoryInvalidResponse): + TeppProjectHistoryClient( + "https://tepp.example", transport=duplicate_reference_transport + ).project(request) + + +def test_strict_client_normalizes_raw_provider_errors() -> None: + request = build_tepp_project_history_request( + projection=_canonical_projection(), + tenant_workspace_id=tenant_workspace_reference(["tenant-a"]), + ) + + def provider_failure(url, payload, headers, timeout): + del url, payload, headers, timeout + raise RuntimeError("provider stack trace must not cross the boundary") + + with pytest.raises(TeppProjectHistoryUnavailable, match="request failed") as error: + TeppProjectHistoryClient( + "https://tepp.example", transport=provider_failure + ).project(request) + + assert "provider stack trace" not in str(error.value) + + +def test_validation_fails_closed_without_hiding_canonical_history(monkeypatch) -> None: + projection = _canonical_projection() + unconfigured = validate_project_history_with_tepp( + projection=projection, + tenant_workspace_id=tenant_workspace_reference([]), + transport_url="", + ) + assert unconfigured == { + "status": "not_configured", + "project_history": None, + "next_action_code": "configure_tepp_project_history", + } + + def broken_project(self, request): + del self, request + raise TeppProjectHistoryUnavailable("synthetic outage") + + monkeypatch.setattr(TeppProjectHistoryClient, "project", broken_project) + unavailable = validate_project_history_with_tepp( + projection=projection, + tenant_workspace_id=tenant_workspace_reference([]), + transport_url="https://tepp.example", + ) + assert unavailable["status"] == "unavailable" + assert projection["event_count"] == 3 + + def invalid_project(self, request): + del self, request + raise TeppProjectHistoryInvalidResponse("synthetic invalid response") + + monkeypatch.setattr(TeppProjectHistoryClient, "project", invalid_project) + invalid = validate_project_history_with_tepp( + projection=projection, + tenant_workspace_id=tenant_workspace_reference([]), + transport_url="https://tepp.example", + ) + assert invalid["status"] == "invalid_evidence" + assert projection["event_count"] == 3 + + +class _Acquire: + async def __aenter__(self) -> object: + return object() + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + return None + + +class _Pool: + def acquire(self) -> _Acquire: + return _Acquire() + + +def test_project_history_route_attaches_validation_to_the_canonical_projection(monkeypatch) -> None: + projection = _canonical_projection() + captured: dict[str, object] = {} + + async def fake_projection(connection, **kwargs): + del connection, kwargs + return deepcopy(projection) + + def fake_validate(**kwargs): + captured.update(kwargs) + return { + "status": "validated", + "project_history": {"inference_status": "temporal_association_only"}, + "next_action_code": "open_source_evidence", + } + + monkeypatch.setattr(main, "fetch_project_history_projection", fake_projection) + monkeypatch.setattr(main, "validate_project_history_with_tepp", fake_validate) + monkeypatch.setattr( + main, + "load_settings", + lambda: SimpleNamespace(tepp_transport_url="https://tepp.example"), + ) + account = CurrentAccount( + user_account_id="account-1", + external_subject_id="subject-1", + display_name="Synthetic analyst", + preferred_locale="en", + corporate_entity_ids=frozenset({"tenant-a"}), + permission_codes=frozenset({"post_read"}), + ) + + result = asyncio.run( + main.read_project_history( + project_key="P-100", + focus_post_id=None, + knowledge_cutoff="2026-08-20T12:00:00+00:00", + limit=64, + account=account, + pool=_Pool(), + ) + ) + + assert result["events"] == projection["events"] + assert result["tepp_validation"]["status"] == "validated" + assert captured["projection"]["project_key"] == "P-100" + assert captured["transport_url"] == "https://tepp.example" diff --git a/uv.lock b/uv.lock index 52a3376dd..fe7edf1ef 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.18.0" +version = "2.20.0" source = { editable = "." } dependencies = [ { name = "certifi" },