From f1ff05cb8aaf8311ee2120aed2d15f4d29dbeb1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:10:23 -0700 Subject: [PATCH 01/34] test: specify recovered TEPP project-history boundary --- tests/test_tepp_project_history_recovery.py | 302 ++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 tests/test_tepp_project_history_recovery.py diff --git a/tests/test_tepp_project_history_recovery.py b/tests/test_tepp_project_history_recovery.py new file mode 100644 index 000000000..62e6ba59d --- /dev/null +++ b/tests/test_tepp_project_history_recovery.py @@ -0,0 +1,302 @@ +"""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, + TeppProjectHistoryUnavailable, +) + + +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: (event["occurred_at"], 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, + ) + wire = request + encoded = json.dumps(wire, 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 wire["events"] + for actor in event["actor_ids"] + ) + assert wire["events"][0]["available_at"] == wire["events"][0]["occurred_at"] + assert wire["events"][0]["evidence_text"].startswith("Synthetic contract awarded") + + +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(TeppProjectHistoryUnavailable): + 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(TeppProjectHistoryUnavailable): + TeppProjectHistoryClient( + "https://tepp.example", transport=changed_evidence_transport + ).project(request) + + +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 + + +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" From 93873f782e3b9ecf38f3915e26ac87570ffac34b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:10:39 -0700 Subject: [PATCH 02/34] test: specify TEPP project-history buyer evidence --- .../TeppProjectHistoryEvidence.test.tsx | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 frontend/src/components/TeppProjectHistoryEvidence.test.tsx diff --git a/frontend/src/components/TeppProjectHistoryEvidence.test.tsx b/frontend/src/components/TeppProjectHistoryEvidence.test.tsx new file mode 100644 index 000000000..28ad1df71 --- /dev/null +++ b/frontend/src/components/TeppProjectHistoryEvidence.test.tsx @@ -0,0 +1,70 @@ +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 the TEPP contract boundary 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(/2 participants/i)).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/i)).not.toBeInTheDocument(); + }); +}); From 565e5311c249258505e7c0012e332ad9594ae0a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:11:34 -0700 Subject: [PATCH 03/34] feat: recover strict TEPP project-history client --- lineageweave/tepp_project_history.py | 383 +++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 lineageweave/tepp_project_history.py diff --git a/lineageweave/tepp_project_history.py b/lineageweave/tepp_project_history.py new file mode 100644 index 000000000..3ac017ee6 --- /dev/null +++ b/lineageweave/tepp_project_history.py @@ -0,0 +1,383 @@ +"""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 +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 + +_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"} +) + +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.""" + + +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 for character in normalized) + ): + raise TeppProjectHistoryUnavailable(f"{name} is empty or outside its bound") + return normalized + + +def _utc_timestamp(value: Any, name: str) -> tuple[datetime, str]: + """Parse an RFC 3339 timestamp and return canonical UTC text.""" + + raw = _text(value, name, 64) + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + 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 _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 = _utc_timestamp(payload["occurred_at"], "occurred_at") + available, available_text = _utc_timestamp(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 = _utc_timestamp(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") + return { + "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, + } + + +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") + return { + "finding_code": _code(payload["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 = _utc_timestamp(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=lambda event: (event["occurred_at"], event["event_id"]), + ) + 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 = _utc_timestamp(payload["history_span_start"], "history_span_start") + _, span_end = _utc_timestamp(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) + + 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 + return validate_tepp_project_history_projection(response, request=payload) From 8281e6a51d01a03bd86805c6c0e82b325604458f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:12:10 -0700 Subject: [PATCH 04/34] feat: map canonical project history into TEPP contract --- backend/app/tepp_project_history.py | 208 ++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 backend/app/tepp_project_history.py diff --git a/backend/app/tepp_project_history.py b/backend/app/tepp_project_history.py new file mode 100644 index 000000000..74ab1a42a --- /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 datetime import datetime, timezone +from typing import Any + +from lineageweave.tepp_project_history import ( + PROJECT_HISTORY_CONTRACT_VERSION, + TeppProjectHistoryClient, + TeppProjectHistoryUnavailable, + 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.""" + + if not isinstance(value, str) or not value.strip(): + raise TeppProjectHistoryUnavailable(f"{field_name} is required") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise TeppProjectHistoryUnavailable(f"{field_name} is not ISO-8601") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise TeppProjectHistoryUnavailable(f"{field_name} must include an offset") + return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +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=lambda event: (event["occurred_at"], event["event_id"])) + 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 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", + } From 5c81a5e2b9e8e70d0686fb1166e2028899c7e977 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:12:51 -0700 Subject: [PATCH 05/34] feat: render TEPP validation beside canonical history --- .../components/TeppProjectHistoryEvidence.tsx | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 frontend/src/components/TeppProjectHistoryEvidence.tsx diff --git a/frontend/src/components/TeppProjectHistoryEvidence.tsx b/frontend/src/components/TeppProjectHistoryEvidence.tsx new file mode 100644 index 000000000..97faee597 --- /dev/null +++ b/frontend/src/components/TeppProjectHistoryEvidence.tsx @@ -0,0 +1,195 @@ +import type { Locale } from "../i18n"; +import { useLocale } from "../i18n"; +import type { TeppProjectHistoryValidation } from "../projectHistory"; +import "./TeppProjectHistoryEvidence.css"; + +interface Copy { + heading: string; + eyebrow: string; + boundary: string; + participants: (count: number) => string; + span: string; + findings: string; + noFindings: string; + openEvidence: (label: string) => 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: (count) => `${count} participants in the 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}`, + 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: (count) => `제공된 근거의 참여자 ${count}명`, + span: "검증된 이력 구간", + findings: "TEPP 검토 결과", + noFindings: "TEPP가 명시적 이벤트를 정렬했으며 추가 검토 결과는 없습니다.", + openEvidence: (label) => `근거 열기: ${label}`, + 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: (count) => `所提供证据中的参与者:${count} 人`, + span: "已验证的历史区间", + findings: "TEPP 结果", + noFindings: "TEPP 已对明确事件排序,未返回其他结果。", + openEvidence: (label) => `打开证据:${label}`, + 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: (count) => `提供根拠の参加者 ${count} 名`, + span: "検証済み履歴期間", + findings: "TEPP の結果", + noFindings: "TEPP は明示的イベントを並べ替え、追加の結果は返しませんでした。", + openEvidence: (label) => `根拠を開く: ${label}`, + 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: "仕様変更と引継ぎの記録が選択イベントより前にあります。", + }, + }, +}; + +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 history = validation.project_history; + + if (validation.status !== "validated" || history === null) { + return ( +
+

{copy.heading}

+

{copy.status[validation.status]}

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

{copy.eyebrow}

+

{copy.heading}

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

{copy.boundary}

+
+
+
{copy.participants(history.participant_count)}
+
{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.summary}

    +
    + {finding.evidence_post_ids.map((postId) => { + const label = sourceLabels[postId] ?? postId; + return ( + + ); + })} +
    +
  • + ))} +
+ ) : null} +
+
+ ); +} From 889bddb6ad0baa75a3946f4062a1831d29a7d342 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:13:06 -0700 Subject: [PATCH 06/34] style: add TEPP project-history evidence panel --- .../components/TeppProjectHistoryEvidence.css | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 frontend/src/components/TeppProjectHistoryEvidence.css diff --git a/frontend/src/components/TeppProjectHistoryEvidence.css b/frontend/src/components/TeppProjectHistoryEvidence.css new file mode 100644 index 000000000..e1bc79b10 --- /dev/null +++ b/frontend/src/components/TeppProjectHistoryEvidence.css @@ -0,0 +1,80 @@ +.tepp-project-evidence { + margin: 1rem 0; + padding: 1rem; + border: 1px solid color-mix(in srgb, var(--accent-color, #3157d5) 35%, transparent); + border-radius: 0.9rem; + background: color-mix(in srgb, var(--surface-color, #ffffff) 94%, var(--accent-color, #3157d5)); +} + +.tepp-project-evidence > 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; + } +} From 2a2a1a73b3193acc1c93d1dfdb73e03da4e1e041 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:13:24 -0700 Subject: [PATCH 07/34] docs: add TEPP project-history Storybook states --- .../TeppProjectHistoryEvidence.stories.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 frontend/src/components/TeppProjectHistoryEvidence.stories.tsx 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", + }, + }, +}; From 31c2016820eebe6f695eedc2485657bfa3f1e684 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:14:12 -0700 Subject: [PATCH 08/34] chore: stage one-shot TEPP project-history recovery --- tools/apply_tepp_project_history_recovery.py | 189 +++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tools/apply_tepp_project_history_recovery.py diff --git a/tools/apply_tepp_project_history_recovery.py b/tools/apply_tepp_project_history_recovery.py new file mode 100644 index 000000000..1aa0d406a --- /dev/null +++ b/tools/apply_tepp_project_history_recovery.py @@ -0,0 +1,189 @@ +"""Apply the bounded TEPP project-history recovery on its stacked branch.""" + +from __future__ import annotations + +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + """Replace one exact source fragment and fail if branch context drifted.""" + + file_path = Path(path) + text = file_path.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise RuntimeError(f"expected one anchor in {path}, found {count}") + file_path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def append_once(path: str, marker: str, content: str) -> None: + """Append a documented section only when it is not already present.""" + + file_path = Path(path) + text = file_path.read_text(encoding="utf-8") + if marker in text: + return + file_path.write_text(text.rstrip() + "\n\n" + content.strip() + "\n", encoding="utf-8") + + +def patch_project_history_types() -> None: + replace_once( + "frontend/src/projectHistory.ts", + 'export type ProjectHistoryTruthStatus = "observed" | "inferred";\n' + 'export type ResponsibilityTransitionCode = "continuous" | "handoff" | "assignment_gap";\n', + 'export type ProjectHistoryTruthStatus = "observed" | "inferred";\n\n' + 'export interface TeppProjectHistoryFinding {\n' + ' finding_code: string;\n' + ' summary: string;\n' + ' related_event_ids: string[];\n' + ' evidence_post_ids: string[];\n' + '}\n\n' + 'export interface TeppProjectHistoryMetadata {\n' + ' contract_version: 1;\n' + ' project_key: string;\n' + ' project_name: string;\n' + ' focus_event_id: string;\n' + ' knowledge_cutoff: string;\n' + ' history_span_start: string;\n' + ' history_span_end: string;\n' + ' participant_count: number;\n' + ' inference_status: "temporal_association_only";\n' + ' event_count: number;\n' + ' findings: TeppProjectHistoryFinding[];\n' + '}\n\n' + 'export interface TeppProjectHistoryValidation {\n' + ' status: "validated" | "not_configured" | "unavailable" | "invalid_evidence";\n' + ' project_history: TeppProjectHistoryMetadata | null;\n' + ' next_action_code:\n' + ' | "open_source_evidence"\n' + ' | "configure_tepp_project_history"\n' + ' | "retry_tepp_project_history";\n' + '}\n\n' + 'export type ResponsibilityTransitionCode = "continuous" | "handoff" | "assignment_gap";\n', + ) + replace_once( + "frontend/src/projectHistory.ts", + " distinct_observed_actor_count: number;\n truncated: boolean;\n events: ProjectHistoryEvent[];\n", + " distinct_observed_actor_count: number;\n truncated: boolean;\n" + " tepp_validation?: TeppProjectHistoryValidation;\n" + " events: ProjectHistoryEvent[];\n", + ) + + +def patch_timeline() -> None: + replace_once( + "frontend/src/components/ProjectHistoryTimeline.tsx", + 'import "./ProjectHistoryTimeline.css";\n', + 'import { TeppProjectHistoryEvidence } from "./TeppProjectHistoryEvidence";\n' + 'import "./ProjectHistoryTimeline.css";\n', + ) + replace_once( + "frontend/src/components/ProjectHistoryTimeline.tsx", + ' [event.source_post_id, event.event_title]),\n' + ' )}\n' + ' />\n' + ' ) : null}\n\n' + ' None: + replace_once( + "backend/app/main.py", + "from lineageweave.project_history import normalize_project_key\n", + "from backend.app.tepp_project_history import (\n" + " tenant_workspace_reference,\n" + " validate_project_history_with_tepp,\n" + ")\n" + "from lineageweave.project_history import normalize_project_key\n", + ) + replace_once( + "backend/app/main.py", + """ async with pool.acquire() as conn: + try: + return await fetch_project_history_projection( + conn, + project_key=project_key, + focus_post_id=focus_post_id, + knowledge_cutoff=cutoff, + corporate_entity_ids=list(account.corporate_entity_ids), + limit=limit, + ) + except ProjectHistoryNotFound as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, "project history not found") from exc +""", + """ async with pool.acquire() as conn: + try: + projection = await fetch_project_history_projection( + conn, + project_key=project_key, + focus_post_id=focus_post_id, + knowledge_cutoff=cutoff, + corporate_entity_ids=list(account.corporate_entity_ids), + limit=limit, + ) + 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 +""", + ) + + +def patch_documents() -> None: + replace_once( + "CHANGELOG.md", + "## [2.18.0] - 2026-08-20\n", + "## [2.19.0] - 2026-08-21\n\n" + "### Added\n\n" + "- Recovered the credential-free TEPP project-history validation boundary on top of\n" + " the canonical Buyer timeline. TEPP may return only cutoff-safe temporal\n" + " associations over the exact authorized events; the timeline remains readable\n" + " when TEPP is absent, and no result is labelled as a cause (ADR 0112).\n\n" + "## [2.18.0] - 2026-08-20\n", + ) + append_once( + "docs/product-technical-gap-baseline.md", + "## Recovered TEPP project-history integration (2026-08-21)", + """ +## 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. +""", + ) + + +def main() -> None: + """Apply all exact-context edits.""" + + patch_project_history_types() + patch_timeline() + patch_backend_route() + patch_documents() + + +if __name__ == "__main__": + main() From 0b03ad553937a42fa3daec232f4a6dcda90c9ea1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:14:47 -0700 Subject: [PATCH 09/34] docs: record recovered TEPP project-history boundary --- ...validation-on-canonical-project-history.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/adr/0112-recover-tepp-validation-on-canonical-project-history.md diff --git a/docs/adr/0112-recover-tepp-validation-on-canonical-project-history.md b/docs/adr/0112-recover-tepp-validation-on-canonical-project-history.md new file mode 100644 index 000000000..3e013af8c --- /dev/null +++ b/docs/adr/0112-recover-tepp-validation-on-canonical-project-history.md @@ -0,0 +1,79 @@ +# ADR 0112: 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. +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 or changed evidence fail closed. +7. `temporal_association_only` is the only accepted inference status. Buyer copy states + that the result does not identify a cause. +8. TEPP availability is optional. `not_configured`, `unavailable`, and `invalid_evidence` + states leave the canonical timeline readable and tell the operator or Buyer what to do + next. +9. Global Ask and post-scoped Ask are a subsequent stacked slice and must reuse this same + canonical projection and TEPP envelope. + +## 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. +- 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. +- **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/ From 26b83f06899a2ad416be746d9a6fb13042bb7659 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:15:22 -0700 Subject: [PATCH 10/34] ci: execute verified TEPP project-history recovery --- ...one-shot-tepp-project-history-recovery.yml | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 .github/workflows/one-shot-tepp-project-history-recovery.yml diff --git a/.github/workflows/one-shot-tepp-project-history-recovery.yml b/.github/workflows/one-shot-tepp-project-history-recovery.yml new file mode 100644 index 000000000..a20012ba2 --- /dev/null +++ b/.github/workflows/one-shot-tepp-project-history-recovery.yml @@ -0,0 +1,126 @@ +name: One-shot TEPP project-history recovery + +on: + push: + branches: + - feat/tepp-project-history-recovery-v2210 + +permissions: + contents: write + +concurrency: + group: one-shot-tepp-project-history-recovery + cancel-in-progress: false + +jobs: + recover: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 60 + 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 + steps: + - name: Checkout recovery branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/tepp-project-history-recovery-v2210 + fetch-depth: 0 + persist-credentials: true + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up uv + 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 locked 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 locked frontend dependencies + working-directory: frontend + run: | + corepack enable + pnpm install --frozen-lockfile + + - name: Confirm RED Python contract + run: | + if uv run --frozen python -m pytest -q tests/test_tepp_project_history_recovery.py; then + echo "Expected the route integration contract to fail before applying the recovery." >&2 + exit 1 + fi + + - name: Confirm RED frontend contract + working-directory: frontend + run: | + if pnpm exec vitest run src/components/TeppProjectHistoryEvidence.test.tsx; then + echo "Expected the unconnected component contract to fail before applying the recovery." >&2 + exit 1 + fi + + - name: Apply exact-context recovery + run: python tools/apply_tepp_project_history_recovery.py + + - name: Verify focused Python contracts + run: >- + uv run --frozen python -m pytest -q + tests/test_tepp_project_history_recovery.py + tests/test_project_history_api_contract.py + tests/test_documentation_hygiene.py + + - name: Verify Python syntax + run: uv run --frozen python -m compileall -q lineageweave backend tests + + - name: Verify focused frontend contracts + working-directory: frontend + run: >- + pnpm exec vitest run + src/components/TeppProjectHistoryEvidence.test.tsx + src/components/ProjectHistoryTimeline.test.tsx + + - name: Verify frontend lint and production builds + working-directory: frontend + run: | + pnpm run lint + pnpm run build + pnpm run build-storybook + + - name: Remove one-shot machinery and check patch hygiene + run: | + rm .github/workflows/one-shot-tepp-project-history-recovery.yml + rm tools/apply_tepp_project_history_recovery.py + git diff --check + + - name: Commit verified recovery + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "feat: recover TEPP validation on canonical project history" + git push origin HEAD:feat/tepp-project-history-recovery-v2210 From 19747b6df911a3e05c15234dbd1f50a00d7f0658 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:21:35 -0700 Subject: [PATCH 11/34] fix(i18n): cover Vietnamese TEPP project-history copy --- .../components/TeppProjectHistoryEvidence.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/frontend/src/components/TeppProjectHistoryEvidence.tsx b/frontend/src/components/TeppProjectHistoryEvidence.tsx index 97faee597..5d10bcd95 100644 --- a/frontend/src/components/TeppProjectHistoryEvidence.tsx +++ b/frontend/src/components/TeppProjectHistoryEvidence.tsx @@ -111,6 +111,30 @@ const COPY: Record = { 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: (count) => `${count} 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}`, + 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 { From 065f962370e9a05ec9288590ace51e5836c6aed5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:33:42 -0700 Subject: [PATCH 12/34] test(red): require TEPP evidence on canonical timeline --- .../ProjectHistoryTimeline.tepp.test.tsx | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 frontend/src/components/ProjectHistoryTimeline.tepp.test.tsx 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); + }); +}); From 7d2fea853b7b7b666404551ad425de66cada4373 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:34:09 -0700 Subject: [PATCH 13/34] fix(ci): make TEPP recovery red gate integration-specific --- .../workflows/one-shot-tepp-project-history-recovery.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/one-shot-tepp-project-history-recovery.yml b/.github/workflows/one-shot-tepp-project-history-recovery.yml index a20012ba2..5df80f949 100644 --- a/.github/workflows/one-shot-tepp-project-history-recovery.yml +++ b/.github/workflows/one-shot-tepp-project-history-recovery.yml @@ -69,18 +69,18 @@ jobs: corepack enable pnpm install --frozen-lockfile - - name: Confirm RED Python contract + - name: Confirm RED Python route contract run: | if uv run --frozen python -m pytest -q tests/test_tepp_project_history_recovery.py; then echo "Expected the route integration contract to fail before applying the recovery." >&2 exit 1 fi - - name: Confirm RED frontend contract + - name: Confirm RED canonical-timeline contract working-directory: frontend run: | - if pnpm exec vitest run src/components/TeppProjectHistoryEvidence.test.tsx; then - echo "Expected the unconnected component contract to fail before applying the recovery." >&2 + if pnpm exec vitest run src/components/ProjectHistoryTimeline.tepp.test.tsx; then + echo "Expected the canonical timeline to omit TEPP evidence before applying the recovery." >&2 exit 1 fi @@ -102,6 +102,7 @@ jobs: run: >- pnpm exec vitest run src/components/TeppProjectHistoryEvidence.test.tsx + src/components/ProjectHistoryTimeline.tepp.test.tsx src/components/ProjectHistoryTimeline.test.tsx - name: Verify frontend lint and production builds From 3fd75b063daed3dbcbfe4c5a8c9a20e6de0bf12a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:36:58 -0700 Subject: [PATCH 14/34] fix(ui): harden TEPP evidence semantics and accessibility --- .../components/TeppProjectHistoryEvidence.tsx | 57 ++++++++++++------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/TeppProjectHistoryEvidence.tsx b/frontend/src/components/TeppProjectHistoryEvidence.tsx index 5d10bcd95..6731095d6 100644 --- a/frontend/src/components/TeppProjectHistoryEvidence.tsx +++ b/frontend/src/components/TeppProjectHistoryEvidence.tsx @@ -1,19 +1,25 @@ +import { useId } from "react"; + import type { Locale } from "../i18n"; import { useLocale } from "../i18n"; -import type { TeppProjectHistoryValidation } from "../projectHistory"; +import type { + TeppProjectHistoryFindingCode, + TeppProjectHistoryValidation, +} from "../projectHistory"; import "./TeppProjectHistoryEvidence.css"; interface Copy { heading: string; eyebrow: string; boundary: string; - participants: (count: number) => string; + participants: string; span: string; findings: string; noFindings: string; openEvidence: (label: string) => string; + unnamedEvidence: (index: number) => string; status: Record, string>; - findingLabels: Record; + findingLabels: Record; } const COPY: Record = { @@ -21,11 +27,12 @@ const COPY: Record = { heading: "TEPP temporal validation", eyebrow: "TEPP-connected evidence", boundary: "Temporal association only; this does not identify a cause.", - participants: (count) => `${count} participants in the supplied evidence`, + 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.", @@ -45,11 +52,12 @@ const COPY: Record = { heading: "TEPP 시간 검증", eyebrow: "TEPP 연계 근거", boundary: "시간적 연관만 제시하며 원인을 식별한 결과가 아닙니다.", - participants: (count) => `제공된 근거의 참여자 ${count}명`, + participants: "제공된 근거의 참여자", span: "검증된 이력 구간", findings: "TEPP 검토 결과", noFindings: "TEPP가 명시적 이벤트를 정렬했으며 추가 검토 결과는 없습니다.", openEvidence: (label) => `근거 열기: ${label}`, + unnamedEvidence: (index) => `근거 기록 ${index}`, status: { not_configured: "TEPP 프로젝트 이력 엔드포인트를 설정한 뒤 이 타임라인을 다시 검증하세요.", unavailable: "TEPP를 사용할 수 없습니다. 현재는 기준 타임라인을 읽고 나중에 검증을 다시 실행하세요.", @@ -69,11 +77,12 @@ const COPY: Record = { heading: "TEPP 时间验证", eyebrow: "TEPP 关联证据", boundary: "仅表示时间关联,不等于识别了原因。", - participants: (count) => `所提供证据中的参与者:${count} 人`, + participants: "所提供证据中的参与者", span: "已验证的历史区间", findings: "TEPP 结果", noFindings: "TEPP 已对明确事件排序,未返回其他结果。", openEvidence: (label) => `打开证据:${label}`, + unnamedEvidence: (index) => `证据记录 ${index}`, status: { not_configured: "请配置 TEPP 项目历史端点,然后重新验证此时间线。", unavailable: "TEPP 当前不可用。请先阅读标准时间线,稍后重试验证。", @@ -92,11 +101,12 @@ const COPY: Record = { heading: "TEPP 時間検証", eyebrow: "TEPP 連携根拠", boundary: "時間的関連のみを示し、原因を特定した結果ではありません。", - participants: (count) => `提供根拠の参加者 ${count} 名`, + participants: "提供根拠の参加者", span: "検証済み履歴期間", findings: "TEPP の結果", noFindings: "TEPP は明示的イベントを並べ替え、追加の結果は返しませんでした。", openEvidence: (label) => `根拠を開く: ${label}`, + unnamedEvidence: (index) => `根拠記録 ${index}`, status: { not_configured: "TEPP プロジェクト履歴エンドポイントを設定し、このタイムラインを再検証してください。", unavailable: "TEPP は利用できません。標準タイムラインを読み、後で検証を再試行してください。", @@ -115,11 +125,12 @@ const COPY: Record = { 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: (count) => `${count} chủ thể trong bằng chứng đã cung cấp`, + 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.", @@ -153,30 +164,32 @@ export function TeppProjectHistoryEvidence({ }) { const locale = useLocale(); const copy = COPY[locale]; - const history = validation.project_history; + const headingId = useId(); + const findingsHeadingId = useId(); - if (validation.status !== "validated" || history === null) { + if (validation.status !== "validated") { return ( -
-

{copy.heading}

+
+

{copy.heading}

{copy.status[validation.status]}

); } + const history = validation.project_history; return ( -
+

{copy.eyebrow}

-

{copy.heading}

+

{copy.heading}

TEPP · v{history.contract_version}

{copy.boundary}

-
{copy.participants(history.participant_count)}
+
{copy.participants}
{history.participant_count}
@@ -186,17 +199,19 @@ export function TeppProjectHistoryEvidence({
-
-
{copy.findings}
+
+
{copy.findings}
{history.findings.length === 0 ?

{copy.noFindings}

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

    {copy.findingLabels[finding.finding_code] ?? finding.summary}

    +
  • +

    {copy.findingLabels[finding.finding_code]}

    - {finding.evidence_post_ids.map((postId) => { - const label = sourceLabels[postId] ?? postId; + {finding.evidence_post_ids.map((postId, index) => { + const label = sourceLabels[postId] ?? copy.unnamedEvidence(index + 1); return (
    ))} {answer && !exchanges.some((row) => row.answer_text === answer.answer_text) && ( @@ -425,6 +442,12 @@ function ChatPanel({ citedPostIds={answer.cited_post_ids} onOpenEvidence={setEvidencePostId} /> + )} {!nameFirstAsk && evidencePostId ? ( @@ -4598,6 +4621,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; @@ -4616,11 +4645,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); @@ -4670,6 +4707,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/lineageweave/__init__.py b/lineageweave/__init__.py index bdfb418d0..2cb406a3f 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.19.0" +__version__ = "2.20.0" 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 ec0d44278..8d34399ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.19.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/uv.lock b/uv.lock index 52e5dd21a..fe7edf1ef 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.19.0" +version = "2.20.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 923580759f0cd7c1a89f1d2f1782df14589c2525 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 19:31:48 +0900 Subject: [PATCH 34/34] docs: assign unique ADR numbers after stack merge --- CHANGELOG.md | 2 +- docs/adr/0113-project-history-links-in-ask-surfaces.md | 2 +- ...127-recover-tepp-validation-on-canonical-project-history.md} | 2 +- ...orkspace.md => 0129-customer-master-three-pane-workspace.md} | 2 +- docs/product-technical-gap-baseline.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename docs/adr/{0112-recover-tepp-validation-on-canonical-project-history.md => 0127-recover-tepp-validation-on-canonical-project-history.md} (98%) rename docs/adr/{0125-customer-master-three-pane-workspace.md => 0129-customer-master-three-pane-workspace.md} (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index c34812a25..bce904fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ All notable changes to this project are documented here. Format follows - 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 0112). + when TEPP is absent, and no result is labelled as a cause (ADR 0127). ## [2.18.0] - 2026-08-20 diff --git a/docs/adr/0113-project-history-links-in-ask-surfaces.md b/docs/adr/0113-project-history-links-in-ask-surfaces.md index b009fe847..de521c1c0 100644 --- a/docs/adr/0113-project-history-links-in-ask-surfaces.md +++ b/docs/adr/0113-project-history-links-in-ask-surfaces.md @@ -2,7 +2,7 @@ - Status: Proposed - Date: 2026-08-21 -- Depends on: ADR 0112 and the canonical Project history read model +- Depends on: ADR 0112, ADR 0127, and the canonical Project history read model ## Context diff --git a/docs/adr/0112-recover-tepp-validation-on-canonical-project-history.md b/docs/adr/0127-recover-tepp-validation-on-canonical-project-history.md similarity index 98% rename from docs/adr/0112-recover-tepp-validation-on-canonical-project-history.md rename to docs/adr/0127-recover-tepp-validation-on-canonical-project-history.md index ade0d032a..5a42d964e 100644 --- a/docs/adr/0112-recover-tepp-validation-on-canonical-project-history.md +++ b/docs/adr/0127-recover-tepp-validation-on-canonical-project-history.md @@ -1,4 +1,4 @@ -# ADR 0112: Recover TEPP validation on the canonical project history +# ADR 0127: Recover TEPP validation on the canonical project history - Status: Proposed - Date: 2026-08-21 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 606af0cf7..1f30d0d79 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -347,7 +347,7 @@ runtime note into a shipped/live claim. ## 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