diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 26c2a6994..c5c277427 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -281,8 +281,8 @@ HTML. `src/api.ts` calls the FastAPI backend directly with the token
Keycloak issued; `src/App.tsx` renders a git-branch SVG of
`GET /api/lineage` (click a node to open that post; `post_admin` can
rebuild), the post list with a named Weekly VOC ISO-8601 week filter
-(ADR 0092; opening that filtered post focuses Event Lineage, ADR 0093).
-Calendar commitments use the same Event Lineage focus path (ADR 0094).
+(ADR 0092; opening that filtered post focuses Event Lineage, ADR 0093),
+Calendar commitments use the same Event Lineage focus path (ADR 0094),
Customer master related posts use the same Event Lineage focus path
(ADR 0095). Ask Agent cited posts use the same Event Lineage focus path
(ADR 0096). A linked Event Lineage node opened from a focused popup keeps
diff --git a/backend/app/main.py b/backend/app/main.py
index 994b64471..d2a1c98fc 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -198,6 +198,7 @@
require_summary_source_body,
)
from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
+from backend.app.project_history_api import router as project_history_router
from backend.app.demo_scope import (
fetch_demo_corporate_entity_ids,
has_real_source_context,
@@ -257,6 +258,7 @@ async def lifespan(app: FastAPI):
allow_methods=["GET", "POST", "PATCH"],
allow_headers=["Authorization"],
)
+app.include_router(project_history_router)
def _require_post_read(account: CurrentAccount) -> None:
diff --git a/backend/app/project_history.py b/backend/app/project_history.py
new file mode 100644
index 000000000..68d163ae5
--- /dev/null
+++ b/backend/app/project_history.py
@@ -0,0 +1,220 @@
+"""ABAC-safe PostgreSQL projection for Buyer project histories."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from datetime import datetime
+from typing import Any, Protocol
+
+from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
+from lineageweave.project_history import build_project_history_projection, normalize_project_key
+
+PROJECT_HISTORY_DEFAULT_LIMIT = 64
+PROJECT_HISTORY_MAXIMUM_LIMIT = 128
+
+
+class ProjectHistoryConnection(Protocol):
+ """Minimal asynchronous query port required by this repository."""
+
+ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]:
+ """Execute a bounded read query and return mapping-like rows."""
+
+ ...
+
+
+_ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")
+_PROJECT_MATCH = """
+(
+ lower(normalize(btrim(coalesce(post.source_project_code, '')), NFKC)) = $1
+ or lower(normalize(btrim(coalesce(post.source_project_name, '')), NFKC)) = $1
+ or exists (
+ select 1
+ from post_project_mention mention
+ where mention.post_id = post.post_id
+ and (
+ lower(normalize(btrim(mention.project_key), NFKC)) = $1
+ or lower(normalize(btrim(mention.project_name), NFKC)) = $1
+ )
+ )
+)
+"""
+_EVENT_SQL = f"""
+select post.post_id,
+ post.post_title,
+ post.created_at,
+ post.voc_type_code,
+ post.source_stage_code,
+ post.source_detail_state_code
+ from source_post post
+ where (post.visibility_code = 'public'
+ or post.corporate_entity_id::text = any($2::text[]))
+ and {_ELIGIBILITY}
+ and post.created_at <= $3
+ and {_PROJECT_MATCH}
+ order by post.created_at, post.post_id
+ limit $4
+"""
+_FOCUS_SQL = f"""
+select post.post_id,
+ post.post_title,
+ post.created_at,
+ post.voc_type_code,
+ post.source_stage_code,
+ post.source_detail_state_code
+ from source_post post
+ where (post.visibility_code = 'public'
+ or post.corporate_entity_id::text = any($2::text[]))
+ and {_ELIGIBILITY}
+ and post.created_at <= $3
+ and post.post_id = $4::uuid
+ and {_PROJECT_MATCH}
+ limit 1
+"""
+_MATCH_SQL = """
+select post.post_id,
+ 'source_project_code'::text as match_kind_code,
+ post.source_project_code as matched_value,
+ null::numeric as confidence,
+ null::text as ontology_iri,
+ 'source_post.source_project_code'::text as provenance
+ from source_post post
+ where post.post_id = any($1::uuid[])
+ and lower(normalize(btrim(coalesce(post.source_project_code, '')), NFKC)) = $2
+union all
+select post.post_id,
+ 'source_project_name'::text,
+ post.source_project_name,
+ null::numeric,
+ null::text,
+ 'source_post.source_project_name'::text
+ from source_post post
+ where post.post_id = any($1::uuid[])
+ and lower(normalize(btrim(coalesce(post.source_project_name, '')), NFKC)) = $2
+union all
+select mention.post_id,
+ 'semantic_project_key'::text,
+ mention.project_key,
+ mention.confidence,
+ mention.ontology_iri,
+ 'post_project_mention.project_key'::text
+ from post_project_mention mention
+ where mention.post_id = any($1::uuid[])
+ and lower(normalize(btrim(mention.project_key), NFKC)) = $2
+union all
+select mention.post_id,
+ 'semantic_project_name'::text,
+ mention.project_name,
+ mention.confidence,
+ mention.ontology_iri,
+ 'post_project_mention.project_name'::text
+ from post_project_mention mention
+ where mention.post_id = any($1::uuid[])
+ and lower(normalize(btrim(mention.project_name), NFKC)) = $2
+order by post_id, match_kind_code, matched_value
+"""
+_ROLE_SQL = """
+select role.post_id,
+ role.actor_name,
+ role.responsibility,
+ role.actor_type_code,
+ role.affiliated_organization_name,
+ role.cataloged_person_id,
+ role.cataloged_team_id,
+ role.cataloged_corporate_entity_id
+ from post_summary_role role
+ where role.post_id = any($1::uuid[])
+ order by role.post_id, role.actor_type_code, role.actor_name, role.responsibility
+"""
+_EDGE_SQL = """
+select edge.parent_post_id, edge.child_post_id, edge.fused_score
+ from post_lineage_edge edge
+ where edge.parent_post_id = any($1::uuid[])
+ and edge.child_post_id = any($1::uuid[])
+ order by edge.child_post_id, edge.parent_post_id
+"""
+
+
+class ProjectHistoryNotFound(LookupError):
+ """No authorized project history matched the requested identity."""
+
+
+async def fetch_project_history_projection(
+ conn: ProjectHistoryConnection,
+ *,
+ project_key: str,
+ focus_post_id: str | None,
+ knowledge_cutoff: datetime,
+ corporate_entity_ids: Sequence[str],
+ limit: int = PROJECT_HISTORY_DEFAULT_LIMIT,
+) -> dict[str, Any]:
+ """Return a bounded project history from authorized PostgreSQL evidence.
+
+ The query applies source eligibility, cutoff, and ABAC before selecting
+ event IDs. All subsequent match, role, and lineage reads are constrained to
+ that visible ID set, so hidden rows cannot affect counts, transitions, or
+ prior-history paths. An authorized focus event remains in a truncated
+ projection even when it falls beyond the earliest page.
+ """
+
+ if limit < 1 or limit > PROJECT_HISTORY_MAXIMUM_LIMIT:
+ raise ValueError("project history limit is outside the supported bound")
+ normalized_key = normalize_project_key(project_key)
+ rows = list(
+ await conn.fetch(
+ _EVENT_SQL,
+ normalized_key,
+ list(corporate_entity_ids),
+ knowledge_cutoff,
+ limit + 1,
+ )
+ )
+ truncated = len(rows) > limit
+ event_rows = rows[:limit]
+ if not event_rows:
+ raise ProjectHistoryNotFound(project_key)
+ visible_ids = [str(row["post_id"]) for row in event_rows]
+ if focus_post_id is not None and focus_post_id not in set(visible_ids):
+ focus_rows = list(
+ await conn.fetch(
+ _FOCUS_SQL,
+ normalized_key,
+ list(corporate_entity_ids),
+ knowledge_cutoff,
+ focus_post_id,
+ )
+ )
+ if not focus_rows:
+ raise ProjectHistoryNotFound(project_key)
+ truncated = True
+ event_rows = (event_rows[: limit - 1] if limit > 1 else []) + [focus_rows[0]]
+ event_rows.sort(key=lambda row: (row["created_at"], str(row["post_id"])))
+ visible_ids = [str(row["post_id"]) for row in event_rows]
+
+ match_rows, role_rows, edge_rows = await _fetch_project_children(
+ conn,
+ visible_ids=visible_ids,
+ normalized_key=normalized_key,
+ )
+ return build_project_history_projection(
+ project_key=project_key,
+ focus_event_id=focus_post_id,
+ event_rows=event_rows,
+ match_rows=match_rows,
+ role_rows=role_rows,
+ edge_rows=edge_rows,
+ truncated=truncated,
+ )
+
+
+async def _fetch_project_children(
+ conn: ProjectHistoryConnection,
+ *,
+ visible_ids: Sequence[str],
+ normalized_key: str,
+) -> tuple[list[Mapping[str, Any]], list[Mapping[str, Any]], list[Mapping[str, Any]]]:
+ """Fetch only child evidence whose endpoints are already authorized."""
+
+ matches = list(await conn.fetch(_MATCH_SQL, list(visible_ids), normalized_key))
+ roles = list(await conn.fetch(_ROLE_SQL, list(visible_ids)))
+ edges = list(await conn.fetch(_EDGE_SQL, list(visible_ids)))
+ return matches, roles, edges
diff --git a/backend/app/project_history_api.py b/backend/app/project_history_api.py
new file mode 100644
index 000000000..d226672d0
--- /dev/null
+++ b/backend/app/project_history_api.py
@@ -0,0 +1,170 @@
+"""Versioned HTTP contract for evidence-bound project-history timelines."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any, Literal
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from pydantic import BaseModel, ConfigDict, Field
+
+from backend.app.auth import CurrentAccount, get_current_account
+from backend.app.db import get_pool
+from backend.app.project_history import (
+ PROJECT_HISTORY_DEFAULT_LIMIT,
+ PROJECT_HISTORY_MAXIMUM_LIMIT,
+ ProjectHistoryNotFound,
+ fetch_project_history_projection,
+)
+from backend.app.source_post_revision import parse_as_of_clock
+
+router = APIRouter()
+
+
+class ProjectHistoryMatch(BaseModel):
+ """One explicit or semantic fact binding a source record to a project."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ match_kind_code: str
+ matched_value: str
+ truth_status_code: Literal["observed", "inferred"]
+ confidence: float | None
+ ontology_iri: str | None
+ provenance: str
+
+
+class ProjectHistoryResponsibility(BaseModel):
+ """One responsibility observed in a source record, not an HR assignment."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ actor_key: str
+ actor_name: str
+ actor_type_code: str
+ affiliated_organization_name: str | None
+ responsibility: str
+ truth_status_code: Literal["observed"]
+ provenance: Literal["post_summary_role"]
+
+
+class ProjectHistoryPathEdge(BaseModel):
+ """One persisted inferred lineage edge inside a visible prior path."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ parent_event_id: str
+ child_event_id: str
+ fused_score: float
+
+
+class ProjectHistoryPriorPath(BaseModel):
+ """A visible-only, non-causal shortest path from a prior event."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ source_event_id: str
+ target_event_id: str
+ event_ids: list[str]
+ edges: list[ProjectHistoryPathEdge]
+ minimum_fused_score: float
+ truth_status_code: Literal["inferred"]
+ source_relation_code: Literal["post_lineage_edge"]
+ provenance: Literal["post_lineage_edge.fused_score"]
+
+
+class ProjectHistoryEvent(BaseModel):
+ """One authorized source record on the chronological Buyer timeline."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ event_id: str
+ source_post_id: str
+ event_title: str
+ event_type_code: str
+ event_type_basis_code: Literal["display_classification"]
+ occurred_at: str
+ time_basis_code: Literal["document_time"]
+ voc_type_code: str | None
+ source_stage_code: str | None
+ source_detail_state_code: str | None
+ project_matches: list[ProjectHistoryMatch]
+ observed_responsibilities: list[ProjectHistoryResponsibility]
+ responsibility_transition_code: Literal["continuous", "handoff", "assignment_gap"] | None
+ related_prior_paths: list[ProjectHistoryPriorPath]
+
+
+class ProjectHistoryProjection(BaseModel):
+ """Strict version-one project-history response contract."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ contract_version: Literal[1]
+ project_key: str
+ normalized_project_key: str
+ project_name: str
+ focus_event_id: str
+ time_basis_code: Literal["document_time"]
+ event_count: int = Field(ge=0)
+ distinct_observed_actor_count: int = Field(ge=0)
+ truncated: bool
+ events: list[ProjectHistoryEvent]
+
+
+def _parse_knowledge_cutoff(value: str | None) -> datetime:
+ """Return the explicit cutoff or the current UTC clock for a live read."""
+
+ if value is None:
+ return datetime.now(timezone.utc)
+ try:
+ return parse_as_of_clock(value)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(
+ 422,
+ "knowledge_cutoff must be an ISO-8601 timestamp",
+ ) from exc
+
+
+@router.get("/api/project-history", response_model=ProjectHistoryProjection)
+async def read_project_history(
+ project_key: str = Query(min_length=1, max_length=512),
+ focus_post_id: UUID | None = Query(default=None),
+ knowledge_cutoff: str | None = Query(default=None),
+ limit: int = Query(
+ default=PROJECT_HISTORY_DEFAULT_LIMIT,
+ ge=1,
+ le=PROJECT_HISTORY_MAXIMUM_LIMIT,
+ ),
+ account: CurrentAccount = Depends(get_current_account),
+ pool: Any = Depends(get_pool),
+) -> dict[str, Any]:
+ """Return one ABAC-safe project timeline without revealing hidden matches."""
+
+ if not account.has_permission("post_read"):
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "account lacks the post_read permission",
+ )
+ cutoff = _parse_knowledge_cutoff(knowledge_cutoff)
+ try:
+ async with pool.acquire() as connection:
+ projection = await fetch_project_history_projection(
+ connection,
+ project_key=project_key,
+ focus_post_id=str(focus_post_id) if focus_post_id is not None else None,
+ knowledge_cutoff=cutoff,
+ corporate_entity_ids=sorted(account.corporate_entity_ids),
+ limit=limit,
+ )
+ except ProjectHistoryNotFound as exc:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ "project history not found",
+ ) from exc
+ except ValueError as exc:
+ raise HTTPException(
+ 422,
+ "project history request is invalid",
+ ) from exc
+ return ProjectHistoryProjection.model_validate(projection).model_dump(mode="json")
diff --git a/backend/app/tepp_project_history.py b/backend/app/tepp_project_history.py
new file mode 100644
index 000000000..93ee4f33d
--- /dev/null
+++ b/backend/app/tepp_project_history.py
@@ -0,0 +1,270 @@
+"""Select authorized project evidence and build TEPP history requests.
+
+The database remains authoritative for post visibility and source metadata.
+This module sends only bounded event labels, evidence excerpts, opaque post and
+actor references, project identity, and clocks. It does not send a raw body,
+provider credential, score, or causal conclusion.
+"""
+
+from __future__ import annotations
+
+import hashlib
+from collections.abc import Callable, Mapping, Sequence
+from datetime import datetime, timezone
+from typing import Any
+
+import asyncpg
+
+from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
+from lineageweave.tepp_project_history import (
+ PROJECT_HISTORY_CONTRACT_VERSION,
+ ProjectHistoryEvent,
+ ProjectHistoryRequest,
+)
+
+_EVENT_PATTERNS: tuple[tuple[str, tuple[str, ...]], ...] = (
+ ("rebid_started", ("rebid", "re-bid", "retender", "re-tender", "재입찰")),
+ (
+ "handoff_recorded",
+ ("handoff", "hand-off", "transferred ownership", "operational transfer", "인수인계"),
+ ),
+ (
+ "specification_changed",
+ (
+ "specification change",
+ "specification revision",
+ "revised specification",
+ "spec revision",
+ "사양 변경",
+ "사양변경",
+ ),
+ ),
+ (
+ "delivered",
+ (
+ "delivery confirmed",
+ "delivery completed",
+ "delivered",
+ "shipment completed",
+ "납품 완료",
+ "납품완료",
+ ),
+ ),
+ (
+ "contract_awarded",
+ (
+ "contract awarded",
+ "award confirmed",
+ "order confirmation",
+ "purchase order received",
+ "수주 확정",
+ "수주확정",
+ ),
+ ),
+)
+_VOC_CODES = frozenset({"voc", "vocc", "voco", "vom", "vop"})
+
+
+def classify_event_type(
+ post_title: str,
+ source_stage_code: str | None,
+ source_detail_state_code: str | None,
+ voc_type_code: str | None,
+ is_focus: bool,
+) -> str:
+ """Map explicit structured/title evidence to TEPP's bounded event vocabulary.
+
+ A generic VOC-family row is not automatically another VOC event. Only the
+ focused row gets that fallback; non-focus rows require explicit event
+ language and otherwise remain ``source_recorded``.
+ """
+ text = " ".join(
+ value.strip().casefold()
+ for value in (post_title, source_stage_code or "", source_detail_state_code or "")
+ if value.strip()
+ )
+ for event_type_code, patterns in _EVENT_PATTERNS:
+ if any(pattern in text for pattern in patterns):
+ return event_type_code
+ if is_focus and (voc_type_code or "").casefold() in _VOC_CODES:
+ return "voc_received"
+ return "source_recorded"
+
+
+def _as_utc_rfc3339(value: datetime) -> str:
+ """Serialize one aware or assumed-UTC datetime as canonical UTC text."""
+ aware = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
+ return aware.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def _bounded_evidence(value: object, fallback: str) -> str:
+ """Return a compact evidence excerpt without forwarding raw source bodies."""
+ text = str(value or "").strip() or fallback.strip()
+ encoded = text.encode("utf-8")
+ if len(encoded) <= 4096:
+ return text
+ return encoded[:4096].decode("utf-8", errors="ignore").rstrip()
+
+
+def _project_identity(focus: Mapping[str, Any]) -> tuple[str, str]:
+ """Choose a stable existing project identity without inventing one."""
+ project_code = str(focus.get("source_project_code") or "").strip()
+ project_name = str(focus.get("source_project_name") or "").strip()
+ grouping_key = str(focus.get("secondary_grouping_key") or "").strip()
+ if project_code:
+ return project_code, project_name or project_code
+ if grouping_key:
+ return grouping_key, project_name or grouping_key
+ post_id = str(focus["post_id"])
+ return f"post:{post_id}", project_name or str(focus["post_title"])
+
+
+def build_project_history_request(
+ rows: Sequence[Mapping[str, Any]],
+ *,
+ focus_post_id: str,
+ tenant_workspace_id: str,
+ knowledge_cutoff: datetime,
+) -> ProjectHistoryRequest:
+ """Build the exact TEPP request from already-authorized source rows.
+
+ Raises:
+ ValueError: no focus row exists, the cutoff excludes an event, or the
+ selected rows do not share the focus project's explicit identity.
+ """
+ focus_rows = [row for row in rows if str(row["post_id"]) == focus_post_id]
+ if len(focus_rows) != 1:
+ raise ValueError("project history requires one visible focus post")
+ focus = focus_rows[0]
+ project_key, project_name = _project_identity(focus)
+ cutoff = knowledge_cutoff if knowledge_cutoff.tzinfo is not None else knowledge_cutoff.replace(
+ tzinfo=timezone.utc
+ )
+ cutoff = cutoff.astimezone(timezone.utc)
+
+ events: list[ProjectHistoryEvent] = []
+ for row in sorted(rows, key=lambda item: (item["created_at"], str(item["post_id"]))):
+ event_time = row["created_at"]
+ if not isinstance(event_time, datetime):
+ raise ValueError("project-history event time must be a datetime")
+ event_time_utc = (
+ event_time if event_time.tzinfo is not None else event_time.replace(tzinfo=timezone.utc)
+ ).astimezone(timezone.utc)
+ if event_time_utc > cutoff:
+ raise ValueError("project-history evidence is after the knowledge cutoff")
+ actor_ids = tuple(
+ sorted({str(value).strip() for value in row.get("actor_ids", ()) if str(value).strip()})
+ )
+ post_id = str(row["post_id"])
+ title = str(row["post_title"])
+ events.append(
+ ProjectHistoryEvent(
+ event_id=post_id,
+ event_type_code=classify_event_type(
+ title,
+ row.get("source_stage_code"),
+ row.get("source_detail_state_code"),
+ row.get("voc_type_code"),
+ post_id == focus_post_id,
+ ),
+ event_title=title,
+ occurred_at=_as_utc_rfc3339(event_time_utc),
+ available_at=_as_utc_rfc3339(event_time_utc),
+ availability_basis_code="source_created_at_proxy",
+ source_post_id=post_id,
+ evidence_text=_bounded_evidence(row.get("evidence_text"), title),
+ actor_ids=actor_ids,
+ )
+ )
+ if not events:
+ raise ValueError("project history has no authorized events")
+
+ digest_material = "\u001f".join(
+ [tenant_workspace_id, project_key, _as_utc_rfc3339(cutoff), *(event.event_id for event in events)]
+ )
+ idempotency_key = hashlib.sha256(digest_material.encode("utf-8")).hexdigest()
+ return ProjectHistoryRequest(
+ contract_version=PROJECT_HISTORY_CONTRACT_VERSION,
+ idempotency_key=idempotency_key,
+ tenant_workspace_id=tenant_workspace_id,
+ project_key=project_key,
+ project_name=project_name,
+ knowledge_cutoff=_as_utc_rfc3339(cutoff),
+ focus_event_id=focus_post_id,
+ events=tuple(events),
+ )
+
+
+async def fetch_project_history_rows(
+ conn: asyncpg.Connection,
+ *,
+ focus_post_id: str,
+ knowledge_cutoff: datetime,
+ can_see: Callable[[Mapping[str, Any]], bool],
+) -> list[dict[str, Any]]:
+ """Load a bounded, project-coherent, ABAC-visible source evidence set."""
+ focus = await conn.fetchrow(
+ f"""
+ select post_id, post_title, post_body, voc_type_code, visibility_code,
+ corporate_entity_id, created_at, source_stage_code,
+ source_detail_state_code, source_project_code, source_project_name,
+ secondary_grouping_key
+ from source_post
+ where post_id = $1
+ and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="source_post")}
+ """,
+ focus_post_id,
+ )
+ if focus is None or not can_see(focus):
+ return []
+ project_code = str(focus["source_project_code"] or "").strip() or None
+ grouping_key = str(focus["secondary_grouping_key"] or "").strip() or None
+ rows = await conn.fetch(
+ f"""
+ select post.post_id, post.post_title, post.voc_type_code,
+ post.visibility_code, post.corporate_entity_id, post.created_at,
+ post.source_stage_code, post.source_detail_state_code,
+ post.source_project_code, post.source_project_name,
+ post.secondary_grouping_key,
+ coalesce(
+ (select string_agg(event.event_text, '; ' order by event.event_ordinal)
+ from post_summary_event event where event.post_id = post.post_id),
+ btrim(left(source_post_search_text(post.post_body), 1000)),
+ post.post_title
+ ) as evidence_text
+ from source_post post
+ where post.created_at <= $2
+ and (
+ post.post_id = $1
+ or ($3::text is not null and post.source_project_code = $3)
+ or ($4::text is not null and post.secondary_grouping_key = $4)
+ )
+ and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")}
+ order by post.created_at, post.post_id
+ limit 128
+ """,
+ focus_post_id,
+ knowledge_cutoff,
+ project_code,
+ grouping_key,
+ )
+ visible = [dict(row) for row in rows if can_see(row)]
+ post_ids = [row["post_id"] for row in visible]
+ actor_map: dict[str, list[str]] = {str(post_id): [] for post_id in post_ids}
+ if post_ids:
+ actor_rows = await conn.fetch(
+ """
+ select post_id, cataloged_person_id
+ from post_summary_role
+ where post_id = any($1::uuid[])
+ and cataloged_person_id is not null
+ order by post_id, cataloged_person_id
+ """,
+ post_ids,
+ )
+ for actor_row in actor_rows:
+ actor_map[str(actor_row["post_id"])].append(str(actor_row["cataloged_person_id"]))
+ for row in visible:
+ row["actor_ids"] = actor_map.get(str(row["post_id"]), [])
+ row["is_focus"] = str(row["post_id"]) == focus_post_id
+ return visible
diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh
index a4938c4b1..4cfbe19bc 100644
--- a/docker/postgres-init/migrate.sh
+++ b/docker/postgres-init/migrate.sh
@@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do
migration_name=${migration##*/}
case "$migration_name" in
0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;;
- 0051_*|0052_*) ;;
+ 0051_*|0052_*|0053_*) ;;
*) continue ;;
esac
printf 'Applying %s\n' "$migration_name"
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 394833a1f..93bd7561e 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -1,4 +1,5 @@
import { config } from "./config";
+import type { ProjectHistoryProjection } from "./projectHistory";
export interface PostSummary {
post_id: string;
@@ -869,6 +870,19 @@ export function fetchPostLineage(accessToken: string, postId: string): Promise
{
+ const params = new URLSearchParams();
+ params.set("project_key", options.projectKey);
+ params.set("focus_post_id", options.focusPostId);
+ if (options.knowledgeCutoff) {
+ params.set("knowledge_cutoff", options.knowledgeCutoff);
+ }
+ return backendFetch(`/api/project-history?${params.toString()}`, accessToken);
+}
+
export function fetchPostChat(accessToken: string, postId: string): Promise {
return backendFetch(`/api/posts/${postId}/chat`, accessToken);
}
diff --git a/frontend/src/components/ProjectHistoryDisclosure.test.tsx b/frontend/src/components/ProjectHistoryDisclosure.test.tsx
new file mode 100644
index 000000000..5a5228fce
--- /dev/null
+++ b/frontend/src/components/ProjectHistoryDisclosure.test.tsx
@@ -0,0 +1,93 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { ProjectHistoryDisclosure } from "./ProjectHistoryDisclosure";
+
+const projection = {
+ contract_version: 1,
+ project_key: "P-100",
+ normalized_project_key: "p-100",
+ project_name: "Northridge renewal",
+ focus_event_id: "voc",
+ time_basis_code: "document_time",
+ event_count: 1,
+ distinct_observed_actor_count: 0,
+ truncated: false,
+ events: [
+ {
+ event_id: "voc",
+ source_post_id: "post-voc",
+ event_title: "VOC received",
+ event_type_code: "voc_received",
+ event_type_basis_code: "display_classification",
+ occurred_at: "2026-07-30T09:00:00Z",
+ time_basis_code: "document_time",
+ voc_type_code: "voc",
+ source_stage_code: null,
+ source_detail_state_code: null,
+ project_matches: [],
+ observed_responsibilities: [],
+ responsibility_transition_code: null,
+ related_prior_paths: [],
+ },
+ ],
+};
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe("ProjectHistoryDisclosure", () => {
+ it("loads the ABAC endpoint only after the buyer opens the project history", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(projection), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ const onSearch = vi.fn();
+ render(
+ ,
+ );
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("button", { name: "Search related posts" }));
+ expect(onSearch).toHaveBeenCalledWith("P-100");
+ fireEvent.click(screen.getByRole("button", { name: "Open project history" }));
+
+ await screen.findByRole("heading", { name: "Project event timeline" });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(String(url)).toContain("/api/project-history?");
+ expect(String(url)).toContain("project_key=P-100");
+ expect(String(url)).toContain("focus_post_id=post-voc");
+ expect(String(url)).toContain("knowledge_cutoff=2026-08-01T00%3A00%3A00Z");
+ expect(init.headers.Authorization).toBe("Bearer token-1");
+ });
+
+ it("uses one non-leaking unavailable message for hidden, absent, and failed histories", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 404 })));
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Open project history" }));
+ await waitFor(() =>
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Project history is unavailable for this evidence.",
+ ),
+ );
+ expect(screen.queryByText(/hidden|forbidden|not found/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/ProjectHistoryDisclosure.tsx b/frontend/src/components/ProjectHistoryDisclosure.tsx
new file mode 100644
index 000000000..c88aeb241
--- /dev/null
+++ b/frontend/src/components/ProjectHistoryDisclosure.tsx
@@ -0,0 +1,60 @@
+import { useState } from "react";
+
+import { fetchProjectHistory } from "../api";
+import { t, useLocale } from "../i18n";
+import { projectHistoryText, type ProjectHistoryProjection } from "../projectHistory";
+import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline";
+
+export function ProjectHistoryDisclosure({
+ accessToken,
+ projectKey,
+ focusPostId,
+ knowledgeCutoff,
+ onOpenPost,
+ onSearch,
+}: {
+ accessToken: string;
+ projectKey: string;
+ focusPostId: string;
+ knowledgeCutoff?: string;
+ onOpenPost: (postId: string) => void;
+ onSearch?: (projectKey: string) => void;
+}) {
+ const locale = useLocale();
+ const [opened, setOpened] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [projection, setProjection] = useState(null);
+ const [error, setError] = useState(false);
+
+ function open() {
+ if (opened) return;
+ setOpened(true);
+ setLoading(true);
+ fetchProjectHistory(accessToken, { projectKey, focusPostId, knowledgeCutoff })
+ .then((result) => {
+ setProjection(result);
+ setLoading(false);
+ })
+ .catch(() => {
+ setError(true);
+ setLoading(false);
+ });
+ }
+
+ return (
+
+
+
+ {error ? (
+
{projectHistoryText(locale, "historyUnavailable")}
+ ) : null}
+ {!error && !loading && projection ? (
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/components/ProjectHistoryTimeline.css b/frontend/src/components/ProjectHistoryTimeline.css
new file mode 100644
index 000000000..0ff7a031e
--- /dev/null
+++ b/frontend/src/components/ProjectHistoryTimeline.css
@@ -0,0 +1,241 @@
+.project-history {
+ display: grid;
+ gap: 1rem;
+ min-width: 0;
+}
+
+.project-history-header,
+.project-history-detail-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.project-history-header h3,
+.project-history-detail-heading h4 {
+ margin: 0;
+}
+
+.project-history-counts,
+.project-history-time-basis,
+.project-history-warning,
+.project-history-boundary {
+ margin: 0;
+}
+
+.project-history-warning,
+.project-history-boundary {
+ border-inline-start: 0.25rem solid currentColor;
+ padding-inline-start: 0.75rem;
+}
+
+.project-history-tabs {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(10rem, 1fr);
+ overflow-x: auto;
+ padding: 1.5rem 0 0.5rem;
+ position: relative;
+}
+
+.project-history-tabs::before {
+ content: "";
+ position: absolute;
+ inset-inline: 1rem;
+ top: 2rem;
+ border-top: 2px solid var(--border-color, #9aa4b2);
+}
+
+.project-history-tab {
+ appearance: none;
+ background: transparent;
+ border: 0;
+ color: inherit;
+ display: grid;
+ gap: 0.35rem;
+ justify-items: center;
+ min-height: 7rem;
+ padding: 0;
+ position: relative;
+ text-align: center;
+}
+
+.project-history-tab:focus-visible {
+ outline: 3px solid currentColor;
+ outline-offset: 0.25rem;
+}
+
+.project-history-marker {
+ background: currentColor;
+ border: 0.25rem solid var(--surface-color, #fff);
+ border-radius: 50%;
+ box-shadow: 0 0 0 2px currentColor;
+ height: 1rem;
+ width: 1rem;
+ z-index: 1;
+}
+
+.project-history-tab-current .project-history-marker {
+ height: 1.25rem;
+ width: 1.25rem;
+}
+
+.project-history-tab[aria-selected="true"] strong {
+ text-decoration: underline;
+ text-underline-offset: 0.25rem;
+}
+
+.project-history-detail {
+ border: 1px solid var(--border-color, #c8d0da);
+ border-radius: 0.75rem;
+ display: grid;
+ gap: 1rem;
+ padding: 1rem;
+}
+
+.project-history-detail section {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.project-history-detail h5 {
+ margin: 0;
+}
+
+.project-history-facts {
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
+ margin: 0;
+}
+
+.project-history-facts div {
+ display: grid;
+ gap: 0.25rem;
+}
+
+.project-history-facts dt {
+ font-weight: 700;
+}
+
+.project-history-facts dd {
+ margin: 0;
+}
+
+.project-history-transition {
+ font-weight: 700;
+}
+
+.project-history-responsibilities,
+.project-history-paths {
+ display: grid;
+ gap: 0.5rem;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.project-history-responsibilities li,
+.project-history-paths li {
+ border: 1px solid var(--border-color, #d7dde5);
+ border-radius: 0.5rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ padding: 0.75rem;
+}
+
+.project-history-responsibilities li span:not(.project-history-truth) {
+ flex-basis: 100%;
+}
+
+.project-history-paths p {
+ flex: 1 1 20rem;
+ margin: 0;
+}
+
+.project-history-truth {
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ font-size: 0.8rem;
+ padding: 0.1rem 0.5rem;
+}
+
+.project-history-exact-values summary {
+ cursor: pointer;
+ font-weight: 700;
+}
+
+.project-history-table-scroll {
+ overflow-x: auto;
+ padding-top: 0.75rem;
+}
+
+.project-history-table-scroll table {
+ border-collapse: collapse;
+ min-width: 54rem;
+ width: 100%;
+}
+
+.project-history-table-scroll th,
+.project-history-table-scroll td {
+ border: 1px solid var(--border-color, #c8d0da);
+ padding: 0.5rem;
+ text-align: start;
+ vertical-align: top;
+}
+
+@media (max-width: 48rem) {
+ .project-history-tabs {
+ grid-auto-flow: row;
+ grid-auto-rows: auto;
+ overflow: visible;
+ padding: 0;
+ }
+
+ .project-history-tabs::before {
+ border-inline-start: 2px solid var(--border-color, #9aa4b2);
+ border-top: 0;
+ inset-block: 1rem;
+ inset-inline-start: 0.75rem;
+ }
+
+ .project-history-tab {
+ grid-template-columns: 1.5rem minmax(5rem, auto) 1fr;
+ justify-items: start;
+ min-height: auto;
+ padding: 0.5rem 0.5rem 0.5rem 0;
+ text-align: start;
+ }
+
+ .project-history-tab > span:last-child {
+ grid-column: 3;
+ }
+
+ .project-history-header,
+ .project-history-detail-heading {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
+
+@media print {
+ .project-history-tabs,
+ .project-history-detail-heading button {
+ display: none;
+ }
+
+ .project-history-exact-values,
+ .project-history-exact-values > * {
+ display: block !important;
+ }
+
+ .project-history-table-scroll {
+ overflow: visible;
+ }
+
+ .project-history-table-scroll table {
+ min-width: 0;
+ }
+}
diff --git a/frontend/src/components/ProjectHistoryTimeline.stories.tsx b/frontend/src/components/ProjectHistoryTimeline.stories.tsx
new file mode 100644
index 000000000..ca6b1cb38
--- /dev/null
+++ b/frontend/src/components/ProjectHistoryTimeline.stories.tsx
@@ -0,0 +1,97 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import type { ProjectHistoryProjection } from "../projectHistory";
+import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline";
+
+const event = (
+ eventId: string,
+ title: string,
+ type: string,
+ occurredAt: string,
+ transition: "continuous" | "handoff" | "assignment_gap" | null,
+ actorName?: string,
+) => ({
+ event_id: eventId,
+ source_post_id: `post-${eventId}`,
+ event_title: title,
+ event_type_code: type,
+ event_type_basis_code: "display_classification" as const,
+ occurred_at: occurredAt,
+ time_basis_code: "document_time" as const,
+ voc_type_code: eventId === "voc" ? "voc" : "vom",
+ source_stage_code: null,
+ source_detail_state_code: null,
+ project_matches: [],
+ observed_responsibilities: actorName
+ ? [
+ {
+ actor_key: `actor:${actorName}`,
+ actor_name: actorName,
+ actor_type_code: "prov_person",
+ affiliated_organization_name: "Demo Corp",
+ responsibility: `Own ${title.toLowerCase()}`,
+ truth_status_code: "observed" as const,
+ provenance: "post_summary_role" as const,
+ },
+ ]
+ : [],
+ responsibility_transition_code: transition,
+ related_prior_paths: [],
+});
+
+const projection: ProjectHistoryProjection = {
+ contract_version: 1,
+ project_key: "P-100",
+ normalized_project_key: "p-100",
+ project_name: "Northridge renewal",
+ focus_event_id: "voc",
+ time_basis_code: "document_time",
+ event_count: 5,
+ distinct_observed_actor_count: 3,
+ truncated: false,
+ events: [
+ event("award", "Contract awarded", "contract_awarded", "2022-03-11T09:00:00Z", null, "Ada West"),
+ event(
+ "spec",
+ "Specification revision requested",
+ "specification_changed",
+ "2023-06-15T09:00:00Z",
+ "continuous",
+ "Ada West",
+ ),
+ event("delivery", "Delivery confirmed", "delivered", "2024-02-20T09:00:00Z", "handoff", "Priya Nair"),
+ event("voc", "VOC received", "voc_received", "2026-07-30T09:00:00Z", "assignment_gap"),
+ event("rebid", "Rebid started", "rebid_started", "2026-08-10T09:00:00Z", "assignment_gap", "Bid team"),
+ ],
+};
+
+projection.events[3].related_prior_paths = [
+ {
+ source_event_id: "award",
+ target_event_id: "voc",
+ event_ids: ["award", "spec", "delivery", "voc"],
+ edges: [
+ { parent_event_id: "award", child_event_id: "spec", fused_score: 0.91 },
+ { parent_event_id: "spec", child_event_id: "delivery", fused_score: 0.82 },
+ { parent_event_id: "delivery", child_event_id: "voc", fused_score: 0.73 },
+ ],
+ minimum_fused_score: 0.73,
+ truth_status_code: "inferred",
+ source_relation_code: "post_lineage_edge",
+ provenance: "post_lineage_edge.fused_score",
+ },
+];
+
+const meta = {
+ title: "Buyer/Project History Timeline",
+ component: ProjectHistoryTimeline,
+ args: {
+ projection,
+ onOpenPost: () => undefined,
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const AwardToRebid: Story = {};
diff --git a/frontend/src/components/ProjectHistoryTimeline.test.tsx b/frontend/src/components/ProjectHistoryTimeline.test.tsx
new file mode 100644
index 000000000..1dbf7001a
--- /dev/null
+++ b/frontend/src/components/ProjectHistoryTimeline.test.tsx
@@ -0,0 +1,277 @@
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { ProjectHistoryProjection } from "../projectHistory";
+import { ProjectHistoryTimeline } from "./ProjectHistoryTimeline";
+
+
+const projection: ProjectHistoryProjection = {
+ contract_version: 1,
+ project_key: "P-100",
+ normalized_project_key: "p-100",
+ project_name: "Northridge renewal",
+ focus_event_id: "voc",
+ time_basis_code: "document_time",
+ event_count: 5,
+ distinct_observed_actor_count: 3,
+ truncated: false,
+ events: [
+ {
+ event_id: "award",
+ source_post_id: "post-award",
+ event_title: "Contract awarded",
+ event_type_code: "contract_awarded",
+ event_type_basis_code: "display_classification",
+ occurred_at: "2022-03-11T09:00:00Z",
+ time_basis_code: "document_time",
+ voc_type_code: "vom",
+ source_stage_code: null,
+ source_detail_state_code: null,
+ project_matches: [
+ {
+ match_kind_code: "source_project_code",
+ matched_value: "P-100",
+ truth_status_code: "observed",
+ confidence: null,
+ ontology_iri: null,
+ provenance: "source_post.source_project_code",
+ },
+ ],
+ observed_responsibilities: [
+ {
+ actor_key: "person:ada",
+ actor_name: "Ada West",
+ actor_type_code: "prov_person",
+ affiliated_organization_name: "Demo Corp",
+ responsibility: "Own the award",
+ truth_status_code: "observed",
+ provenance: "post_summary_role",
+ },
+ ],
+ responsibility_transition_code: null,
+ related_prior_paths: [],
+ },
+ {
+ event_id: "spec",
+ source_post_id: "post-spec",
+ event_title: "Specification revision requested",
+ event_type_code: "specification_changed",
+ event_type_basis_code: "display_classification",
+ occurred_at: "2023-06-15T09:00:00Z",
+ time_basis_code: "document_time",
+ voc_type_code: "vom",
+ source_stage_code: null,
+ source_detail_state_code: null,
+ project_matches: [],
+ observed_responsibilities: [
+ {
+ actor_key: "person:ada",
+ actor_name: "Ada West",
+ actor_type_code: "prov_person",
+ affiliated_organization_name: "Demo Corp",
+ responsibility: "Own the specification",
+ truth_status_code: "observed",
+ provenance: "post_summary_role",
+ },
+ ],
+ responsibility_transition_code: "continuous",
+ related_prior_paths: [
+ {
+ source_event_id: "award",
+ target_event_id: "spec",
+ event_ids: ["award", "spec"],
+ edges: [
+ {
+ parent_event_id: "award",
+ child_event_id: "spec",
+ fused_score: 0.91,
+ },
+ ],
+ minimum_fused_score: 0.91,
+ truth_status_code: "inferred",
+ source_relation_code: "post_lineage_edge",
+ provenance: "post_lineage_edge.fused_score",
+ },
+ ],
+ },
+ {
+ event_id: "delivery",
+ source_post_id: "post-delivery",
+ event_title: "Delivery confirmed",
+ event_type_code: "delivered",
+ event_type_basis_code: "display_classification",
+ occurred_at: "2024-02-20T09:00:00Z",
+ time_basis_code: "document_time",
+ voc_type_code: "vom",
+ source_stage_code: null,
+ source_detail_state_code: null,
+ project_matches: [],
+ observed_responsibilities: [
+ {
+ actor_key: "person:priya",
+ actor_name: "Priya Nair",
+ actor_type_code: "prov_person",
+ affiliated_organization_name: "Northridge Grid",
+ responsibility: "Own delivery acceptance",
+ truth_status_code: "observed",
+ provenance: "post_summary_role",
+ },
+ ],
+ responsibility_transition_code: "handoff",
+ related_prior_paths: [
+ {
+ source_event_id: "award",
+ target_event_id: "delivery",
+ event_ids: ["award", "spec", "delivery"],
+ edges: [
+ {
+ parent_event_id: "award",
+ child_event_id: "spec",
+ fused_score: 0.91,
+ },
+ {
+ parent_event_id: "spec",
+ child_event_id: "delivery",
+ fused_score: 0.82,
+ },
+ ],
+ minimum_fused_score: 0.82,
+ truth_status_code: "inferred",
+ source_relation_code: "post_lineage_edge",
+ provenance: "post_lineage_edge.fused_score",
+ },
+ ],
+ },
+ {
+ event_id: "voc",
+ source_post_id: "post-voc",
+ event_title: "VOC received",
+ event_type_code: "voc_received",
+ event_type_basis_code: "display_classification",
+ occurred_at: "2026-07-30T09:00:00Z",
+ time_basis_code: "document_time",
+ voc_type_code: "voc",
+ source_stage_code: null,
+ source_detail_state_code: null,
+ project_matches: [],
+ observed_responsibilities: [],
+ responsibility_transition_code: "assignment_gap",
+ related_prior_paths: [
+ {
+ source_event_id: "award",
+ target_event_id: "voc",
+ event_ids: ["award", "spec", "delivery", "voc"],
+ edges: [
+ {
+ parent_event_id: "award",
+ child_event_id: "spec",
+ fused_score: 0.91,
+ },
+ {
+ parent_event_id: "spec",
+ child_event_id: "delivery",
+ fused_score: 0.82,
+ },
+ {
+ parent_event_id: "delivery",
+ child_event_id: "voc",
+ fused_score: 0.73,
+ },
+ ],
+ minimum_fused_score: 0.73,
+ truth_status_code: "inferred",
+ source_relation_code: "post_lineage_edge",
+ provenance: "post_lineage_edge.fused_score",
+ },
+ ],
+ },
+ {
+ event_id: "rebid",
+ source_post_id: "post-rebid",
+ event_title: "Rebid started",
+ event_type_code: "rebid_started",
+ event_type_basis_code: "display_classification",
+ occurred_at: "2026-08-10T09:00:00Z",
+ time_basis_code: "document_time",
+ voc_type_code: "vom",
+ source_stage_code: null,
+ source_detail_state_code: null,
+ project_matches: [],
+ observed_responsibilities: [
+ {
+ actor_key: "team:bid",
+ actor_name: "Bid team",
+ actor_type_code: "prov_team",
+ affiliated_organization_name: "Demo Corp",
+ responsibility: "Prepare the rebid",
+ truth_status_code: "observed",
+ provenance: "post_summary_role",
+ },
+ ],
+ responsibility_transition_code: "assignment_gap",
+ related_prior_paths: [],
+ },
+ ],
+};
+
+
+describe("ProjectHistoryTimeline", () => {
+ it("renders the focus event, exact evidence, and non-causal prior path", () => {
+ const onOpenPost = vi.fn();
+ render();
+
+ expect(screen.getByRole("heading", { name: "Project event timeline" })).toBeInTheDocument();
+ expect(screen.getByText("5 events · 3 observed actors")).toBeInTheDocument();
+ const vocTab = screen.getByRole("tab", { name: /VOC received/ });
+ expect(vocTab).toHaveAttribute("aria-selected", "true");
+ expect(vocTab).toHaveAttribute("aria-current", "step");
+ const detailPanel = screen.getByRole("tabpanel");
+ expect(within(detailPanel).getByText("Assignment evidence gap")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "Contract awarded → Specification revision requested → Delivery confirmed → VOC received",
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/inferred related history, not causality/i)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "Open source record: VOC received" }));
+ expect(onOpenPost).toHaveBeenCalledWith("post-voc");
+ });
+
+ it("supports roving keyboard selection with visible text for handoffs and gaps", () => {
+ render();
+ const vocTab = screen.getByRole("tab", { name: /VOC received/ });
+
+ fireEvent.keyDown(vocTab, { key: "ArrowLeft" });
+ const deliveryTab = screen.getByRole("tab", { name: /Delivery confirmed/ });
+ expect(deliveryTab).toHaveAttribute("aria-selected", "true");
+ expect(deliveryTab).toHaveFocus();
+ const detailPanel = screen.getByRole("tabpanel");
+ expect(within(detailPanel).getByText("Responsibility handoff")).toBeInTheDocument();
+ expect(within(detailPanel).getByText("Priya Nair")).toBeInTheDocument();
+
+ fireEvent.keyDown(deliveryTab, { key: "Home" });
+ expect(screen.getByRole("tab", { name: /Contract awarded/ })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+
+ fireEvent.keyDown(screen.getByRole("tab", { name: /Contract awarded/ }), { key: "End" });
+ expect(screen.getByRole("tab", { name: /Rebid started/ })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ });
+
+ it("provides a complete exact-value table for touch, print, and assistive technology", () => {
+ render();
+ fireEvent.click(screen.getByText("Exact values"));
+
+ const table = screen.getByRole("table", { name: "Project history exact values" });
+ expect(within(table).getAllByRole("row")).toHaveLength(6);
+ expect(within(table).getByText("0.730")).toBeInTheDocument();
+ const vocRow = within(table).getAllByText("VOC received")[0].closest("tr");
+ if (vocRow === null) throw new Error("VOC received row not found");
+ expect(within(vocRow).getByText("Assignment evidence gap")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/ProjectHistoryTimeline.tsx b/frontend/src/components/ProjectHistoryTimeline.tsx
new file mode 100644
index 000000000..f222e0ab2
--- /dev/null
+++ b/frontend/src/components/ProjectHistoryTimeline.tsx
@@ -0,0 +1,292 @@
+import { useRef, useState, type KeyboardEvent } from "react";
+
+import { useLocale } from "../i18n";
+import {
+ type ProjectHistoryEvent,
+ type ProjectHistoryProjection,
+ projectHistoryEventTypeLabel,
+ projectHistoryText,
+ projectHistoryTransitionLabel,
+} from "../projectHistory";
+import "./ProjectHistoryTimeline.css";
+
+function formatDate(value: string): string {
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.valueOf()) ? value : parsed.toISOString().slice(0, 10);
+}
+
+function minimumPathScore(event: ProjectHistoryEvent): number | null {
+ if (event.related_prior_paths.length === 0) return null;
+ return Math.min(...event.related_prior_paths.map((path) => path.minimum_fused_score));
+}
+
+export function ProjectHistoryTimeline({
+ projection,
+ onOpenPost,
+}: {
+ projection: ProjectHistoryProjection;
+ onOpenPost: (postId: string) => void;
+}) {
+ const locale = useLocale();
+ const initialEvent =
+ projection.events.find((event) => event.event_id === projection.focus_event_id) ??
+ projection.events[0];
+ const [selectedEventId, setSelectedEventId] = useState(initialEvent?.event_id ?? "");
+ const tabRefs = useRef>([]);
+ const eventById = new Map(projection.events.map((event) => [event.event_id, event]));
+ const selectedEvent = eventById.get(selectedEventId) ?? initialEvent;
+
+ function selectAt(index: number) {
+ const bounded = Math.max(0, Math.min(index, projection.events.length - 1));
+ const event = projection.events[bounded];
+ if (!event) return;
+ setSelectedEventId(event.event_id);
+ tabRefs.current[bounded]?.focus();
+ }
+
+ function handleTabKey(event: KeyboardEvent, index: number) {
+ let target: number | null = null;
+ switch (event.key) {
+ case "ArrowLeft":
+ case "ArrowUp":
+ target = index === 0 ? projection.events.length - 1 : index - 1;
+ break;
+ case "ArrowRight":
+ case "ArrowDown":
+ target = index === projection.events.length - 1 ? 0 : index + 1;
+ break;
+ case "Home":
+ target = 0;
+ break;
+ case "End":
+ target = projection.events.length - 1;
+ break;
+ default:
+ return;
+ }
+ event.preventDefault();
+ selectAt(target);
+ }
+
+ const selectedPanelId = `project-history-panel-${projection.normalized_project_key.replace(/[^a-z0-9_-]+/g, "-")}`;
+
+ return (
+
+
+
+
{projection.project_name}
+
{projectHistoryText(locale, "heading")}
+
+
+ {projectHistoryText(locale, "summaryCounts", {
+ events: projection.event_count,
+ actors: projection.distinct_observed_actor_count,
+ })}
+
+
+
+ {projectHistoryText(locale, "documentTime")}
+ {projection.truncated ? (
+
+ {projectHistoryText(locale, "truncated")}
+
+ ) : null}
+
+
+ {projection.events.map((event, index) => {
+ const selected = event.event_id === selectedEvent?.event_id;
+ const current = event.event_id === projection.focus_event_id;
+ return (
+
+ );
+ })}
+
+
+ {selectedEvent ? (
+
+
+
+
{projectHistoryText(locale, "eventDetail")}
+
{selectedEvent.event_title}
+
+
+
+
+
+
+
- {projectHistoryText(locale, "eventDate")}
+ - {formatDate(selectedEvent.occurred_at)}
+
+
+
- {projectHistoryText(locale, "eventType")}
+ - {projectHistoryEventTypeLabel(locale, selectedEvent.event_type_code)}
+
+ {selectedEvent.responsibility_transition_code ? (
+
+
- {projectHistoryText(locale, "columnTransition")}
+ -
+ {projectHistoryTransitionLabel(
+ locale,
+ selectedEvent.responsibility_transition_code,
+ )}
+
+
+ ) : null}
+
+
+
+
+ {projectHistoryText(locale, "responsibilityEvidence")}
+
+ {selectedEvent.observed_responsibilities.length > 0 ? (
+
+ {selectedEvent.observed_responsibilities.map((responsibility) => (
+ -
+ {responsibility.actor_name}
+ {responsibility.affiliated_organization_name
+ ? ` · ${responsibility.affiliated_organization_name}`
+ : ""}
+ {responsibility.responsibility}
+
+ {projectHistoryText(locale, "observed")}
+
+
+ ))}
+
+ ) : (
+ {projectHistoryText(locale, "noResponsibilityEvidence")}
+ )}
+
+
+
+ {projectHistoryText(locale, "priorHistory")}
+ {selectedEvent.related_prior_paths.length > 0 ? (
+
+ ) : (
+ {projectHistoryText(locale, "noPriorHistory")}
+ )}
+
+ {projectHistoryText(locale, "inferredBoundary")}
+
+
+
+ {selectedEvent.project_matches.length > 0 ? (
+
+
+ {projectHistoryText(locale, "projectEvidence")}
+
+
+ {selectedEvent.project_matches.map((match) => (
+ -
+ {match.matched_value} · {match.provenance} ·{" "}
+ {projectHistoryText(locale, match.truth_status_code)}
+
+ ))}
+
+
+ ) : null}
+
+ ) : null}
+
+
+ {projectHistoryText(locale, "exactValues")}
+
+
+
+
+ | {projectHistoryText(locale, "columnDate")} |
+ {projectHistoryText(locale, "columnEvent")} |
+ {projectHistoryText(locale, "columnType")} |
+ {projectHistoryText(locale, "columnTransition")} |
+ {projectHistoryText(locale, "columnActors")} |
+ {projectHistoryText(locale, "columnPathScore")} |
+
+
+
+ {projection.events.map((event) => {
+ const pathScore = minimumPathScore(event);
+ return (
+
+ | {formatDate(event.occurred_at)} |
+ {event.event_title} |
+ {projectHistoryEventTypeLabel(locale, event.event_type_code)} |
+
+ {projectHistoryTransitionLabel(locale, event.responsibility_transition_code)}
+ |
+
+ {event.observed_responsibilities.length > 0
+ ? event.observed_responsibilities.map((row) => row.actor_name).join(", ")
+ : projectHistoryText(locale, "notApplicable")}
+ |
+
+ {pathScore === null
+ ? projectHistoryText(locale, "notApplicable")
+ : pathScore.toFixed(3)}
+ |
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/projectHistory.test.ts b/frontend/src/projectHistory.test.ts
new file mode 100644
index 000000000..e1e56617c
--- /dev/null
+++ b/frontend/src/projectHistory.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ groupProjectEvidence,
+ PROJECT_HISTORY_MESSAGE_KEYS,
+ projectHistoryText,
+} from "./projectHistory";
+
+
+describe("project-history evidence grouping", () => {
+ it("converges explicit and semantic project identity without duplicate cards", () => {
+ const groups = groupProjectEvidence([
+ {
+ project_key: "P-100",
+ project_name: "Northridge renewal",
+ evidence: "source_post.source_project_code",
+ confidence: null,
+ ontology_iri: "https://w3id.org/lineageweave#Project",
+ extraction_method: "source_field_hint",
+ resolution_status: "hint_only",
+ provenance: "source_post.source_project_code",
+ },
+ {
+ project_key: "P-100",
+ project_name: "Northridge renewal",
+ evidence: "The project was named in the body.",
+ confidence: 0.91,
+ ontology_iri: "https://w3id.org/lineageweave#Project",
+ extraction_method: "contextual_orchestrator_semantic",
+ resolution_status: "semantic_candidate",
+ provenance: "post_project_mention.evidence_text",
+ },
+ ]);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].projectKey).toBe("P-100");
+ expect(groups[0].projectName).toBe("Northridge renewal");
+ expect(groups[0].evidence).toHaveLength(2);
+ expect(groups[0].evidence[0].extraction_method).toBe("source_field_hint");
+ });
+});
+
+
+describe("project-history locale contract", () => {
+ it.each(["ko", "zh", "ja", "vi"] as const)(
+ "contains every Buyer message in %s",
+ (locale) => {
+ for (const key of PROJECT_HISTORY_MESSAGE_KEYS) {
+ expect(projectHistoryText(locale, key), `${locale}:${key}`).not.toBe(
+ projectHistoryText("en", key),
+ );
+ }
+ },
+ );
+
+ it("formats event and actor counts", () => {
+ expect(projectHistoryText("en", "summaryCounts", { events: 5, actors: 3 })).toBe(
+ "5 events · 3 observed actors",
+ );
+ expect(projectHistoryText("ko", "summaryCounts", { events: 5, actors: 3 })).toBe(
+ "이벤트 5건 · 관찰된 담당자 3명",
+ );
+ });
+});
diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts
new file mode 100644
index 000000000..ca13bc8a8
--- /dev/null
+++ b/frontend/src/projectHistory.ts
@@ -0,0 +1,403 @@
+import type { ProjectEvidence } from "./api";
+import type { Locale } from "./i18n";
+
+export type ProjectHistoryTruthStatus = "observed" | "inferred";
+export type ResponsibilityTransitionCode = "continuous" | "handoff" | "assignment_gap";
+
+export interface ProjectHistoryMatch {
+ match_kind_code: string;
+ matched_value: string;
+ truth_status_code: ProjectHistoryTruthStatus;
+ confidence: number | null;
+ ontology_iri: string | null;
+ provenance: string;
+}
+
+export interface ProjectHistoryResponsibility {
+ actor_key: string;
+ actor_name: string;
+ actor_type_code: string;
+ affiliated_organization_name: string | null;
+ responsibility: string;
+ truth_status_code: "observed";
+ provenance: "post_summary_role";
+}
+
+export interface ProjectHistoryPathEdge {
+ parent_event_id: string;
+ child_event_id: string;
+ fused_score: number;
+}
+
+export interface ProjectHistoryPriorPath {
+ source_event_id: string;
+ target_event_id: string;
+ event_ids: string[];
+ edges: ProjectHistoryPathEdge[];
+ minimum_fused_score: number;
+ truth_status_code: "inferred";
+ source_relation_code: "post_lineage_edge";
+ provenance: "post_lineage_edge.fused_score";
+}
+
+export interface ProjectHistoryEvent {
+ event_id: string;
+ source_post_id: string;
+ event_title: string;
+ event_type_code: string;
+ event_type_basis_code: "display_classification";
+ occurred_at: string;
+ time_basis_code: "document_time";
+ voc_type_code: string | null;
+ source_stage_code: string | null;
+ source_detail_state_code: string | null;
+ project_matches: ProjectHistoryMatch[];
+ observed_responsibilities: ProjectHistoryResponsibility[];
+ responsibility_transition_code: ResponsibilityTransitionCode | null;
+ related_prior_paths: ProjectHistoryPriorPath[];
+}
+
+export interface ProjectHistoryProjection {
+ contract_version: 1;
+ project_key: string;
+ normalized_project_key: string;
+ project_name: string;
+ focus_event_id: string;
+ time_basis_code: "document_time";
+ event_count: number;
+ distinct_observed_actor_count: number;
+ truncated: boolean;
+ events: ProjectHistoryEvent[];
+}
+
+export interface ProjectEvidenceGroup {
+ normalizedProjectKey: string;
+ projectKey: string;
+ projectName: string;
+ evidence: ProjectEvidence[];
+}
+
+function normalizeProjectIdentity(value: string): string {
+ return value.normalize("NFKC").trim().toLocaleLowerCase("en-US");
+}
+
+function evidenceOrder(evidence: ProjectEvidence): number {
+ if (evidence.extraction_method === "source_field_hint") return 0;
+ if (evidence.resolution_status === "hint_only") return 1;
+ return 2;
+}
+
+export function groupProjectEvidence(evidence: ProjectEvidence[]): ProjectEvidenceGroup[] {
+ const groups = new Map();
+ for (const item of evidence) {
+ const normalizedProjectKey = normalizeProjectIdentity(item.project_key || item.project_name);
+ if (!normalizedProjectKey) continue;
+ const existing = groups.get(normalizedProjectKey);
+ if (!existing) {
+ groups.set(normalizedProjectKey, {
+ normalizedProjectKey,
+ projectKey: item.project_key,
+ projectName: item.project_name,
+ evidence: [item],
+ });
+ continue;
+ }
+ existing.evidence.push(item);
+ if (evidenceOrder(item) < evidenceOrder(existing.evidence[0])) {
+ existing.projectKey = item.project_key;
+ existing.projectName = item.project_name;
+ }
+ }
+ return Array.from(groups.values())
+ .map((group) => ({
+ ...group,
+ evidence: [...group.evidence].sort(
+ (left, right) =>
+ evidenceOrder(left) - evidenceOrder(right) ||
+ left.project_name.localeCompare(right.project_name) ||
+ left.provenance.localeCompare(right.provenance),
+ ),
+ }))
+ .sort((left, right) => left.projectName.localeCompare(right.projectName));
+}
+
+const MESSAGE_KEYS = [
+ "heading",
+ "summaryCounts",
+ "documentTime",
+ "truncated",
+ "eventDetail",
+ "eventType",
+ "eventDate",
+ "responsibilityEvidence",
+ "noResponsibilityEvidence",
+ "continuous",
+ "handoff",
+ "assignmentGap",
+ "priorHistory",
+ "noPriorHistory",
+ "inferredBoundary",
+ "projectEvidence",
+ "observed",
+ "inferred",
+ "openSourceRecord",
+ "exactValues",
+ "exactTableLabel",
+ "columnDate",
+ "columnEvent",
+ "columnType",
+ "columnTransition",
+ "columnActors",
+ "columnPathScore",
+ "notApplicable",
+ "contractAwarded",
+ "specificationChanged",
+ "delivered",
+ "handoffRecorded",
+ "vocReceived",
+ "rebidStarted",
+ "sourceRecorded",
+ "openProjectHistory",
+ "historyUnavailable",
+] as const;
+
+export const PROJECT_HISTORY_MESSAGE_KEYS = MESSAGE_KEYS;
+export type ProjectHistoryMessageKey = (typeof MESSAGE_KEYS)[number];
+
+type MessageParams = Record;
+
+const EN: Record = {
+ heading: "Project event timeline",
+ summaryCounts: "{events} events · {actors} observed actors",
+ documentTime: "Dates use document time; they are not asserted event-occurrence times.",
+ truncated: "This bounded timeline is truncated. The selected event remains included.",
+ eventDetail: "Event detail",
+ eventType: "Display event type",
+ eventDate: "Document date",
+ responsibilityEvidence: "Observed responsibility evidence",
+ noResponsibilityEvidence: "No responsibility evidence is recorded for this event.",
+ continuous: "Responsibility continued",
+ handoff: "Responsibility handoff",
+ assignmentGap: "Assignment evidence gap",
+ priorHistory: "Related prior history",
+ noPriorHistory: "No visible prior lineage path is recorded for this event.",
+ inferredBoundary: "This is inferred related history, not causality or an authoritative assignment record.",
+ projectEvidence: "Project identity evidence",
+ observed: "Observed",
+ inferred: "Inferred",
+ openSourceRecord: "Open source record: {title}",
+ exactValues: "Exact values",
+ exactTableLabel: "Project history exact values",
+ columnDate: "Date",
+ columnEvent: "Event",
+ columnType: "Type",
+ columnTransition: "Responsibility transition",
+ columnActors: "Observed actors",
+ columnPathScore: "Minimum lineage score",
+ notApplicable: "Not applicable",
+ contractAwarded: "Contract awarded",
+ specificationChanged: "Specification changed",
+ delivered: "Delivered",
+ handoffRecorded: "Handoff recorded",
+ vocReceived: "VOC received",
+ rebidStarted: "Rebid started",
+ sourceRecorded: "Source record",
+ openProjectHistory: "Open project history",
+ historyUnavailable: "Project history is unavailable for this evidence.",
+};
+
+const MESSAGES: Record> = {
+ en: EN,
+ ko: {
+ heading: "프로젝트 이벤트 타임라인",
+ summaryCounts: "이벤트 {events}건 · 관찰된 담당자 {actors}명",
+ documentTime: "날짜는 문서 시각이며 실제 사건 발생 시각으로 단정하지 않습니다.",
+ truncated: "이 제한된 타임라인은 일부만 표시합니다. 선택한 이벤트는 계속 포함됩니다.",
+ eventDetail: "이벤트 상세",
+ eventType: "표시용 이벤트 유형",
+ eventDate: "문서 날짜",
+ responsibilityEvidence: "관찰된 담당 근거",
+ noResponsibilityEvidence: "이 이벤트에는 기록된 담당 근거가 없습니다.",
+ continuous: "담당 유지",
+ handoff: "담당 변경",
+ assignmentGap: "담당 근거 공백",
+ priorHistory: "관련 과거 이력",
+ noPriorHistory: "이 이벤트로 이어지는 공개 가능한 이전 계보가 없습니다.",
+ inferredBoundary: "이는 추론된 관련 이력이며 인과관계나 권위 있는 인사 배정 기록이 아닙니다.",
+ projectEvidence: "프로젝트 식별 근거",
+ observed: "관찰됨",
+ inferred: "추론됨",
+ openSourceRecord: "원천 기록 열기: {title}",
+ exactValues: "정확한 값",
+ exactTableLabel: "프로젝트 이력 정확한 값",
+ columnDate: "날짜",
+ columnEvent: "이벤트",
+ columnType: "유형",
+ columnTransition: "담당 변화",
+ columnActors: "관찰된 담당자",
+ columnPathScore: "최소 계보 점수",
+ notApplicable: "해당 없음",
+ contractAwarded: "수주 확정",
+ specificationChanged: "사양 변경",
+ delivered: "납품",
+ handoffRecorded: "인수인계 기록",
+ vocReceived: "VOC 접수",
+ rebidStarted: "재입찰 시작",
+ sourceRecorded: "원천 기록",
+ openProjectHistory: "프로젝트 이력 열기",
+ historyUnavailable: "이 근거에 대한 프로젝트 이력을 사용할 수 없습니다.",
+ },
+ zh: {
+ heading: "项目事件时间线",
+ summaryCounts: "{events} 个事件 · {actors} 名已观察责任人",
+ documentTime: "日期采用文档时间,不声称为事件实际发生时间。",
+ truncated: "此有界时间线已截断,但所选事件仍保留。",
+ eventDetail: "事件详情",
+ eventType: "显示事件类型",
+ eventDate: "文档日期",
+ responsibilityEvidence: "已观察的责任证据",
+ noResponsibilityEvidence: "此事件没有记录责任证据。",
+ continuous: "责任持续",
+ handoff: "责任交接",
+ assignmentGap: "责任证据缺口",
+ priorHistory: "相关既往历史",
+ noPriorHistory: "此事件没有可见的既往谱系路径。",
+ inferredBoundary: "这是推断的相关历史,并非因果关系或权威任命记录。",
+ projectEvidence: "项目身份依据",
+ observed: "已观察",
+ inferred: "已推断",
+ openSourceRecord: "打开源记录:{title}",
+ exactValues: "精确值",
+ exactTableLabel: "项目历史精确值",
+ columnDate: "日期",
+ columnEvent: "事件",
+ columnType: "类型",
+ columnTransition: "责任变化",
+ columnActors: "已观察责任人",
+ columnPathScore: "最低谱系分数",
+ notApplicable: "不适用",
+ contractAwarded: "合同授予",
+ specificationChanged: "规格变更",
+ delivered: "已交付",
+ handoffRecorded: "已记录交接",
+ vocReceived: "收到客户之声",
+ rebidStarted: "重新投标开始",
+ sourceRecorded: "源记录",
+ openProjectHistory: "打开项目历史",
+ historyUnavailable: "此证据的项目历史不可用。",
+ },
+ ja: {
+ heading: "プロジェクトイベントのタイムライン",
+ summaryCounts: "イベント {events}件 · 観察された担当者 {actors}名",
+ documentTime: "日付は文書時刻であり、実際のイベント発生時刻とは断定しません。",
+ truncated: "この上限付きタイムラインは省略されていますが、選択イベントは保持されます。",
+ eventDetail: "イベント詳細",
+ eventType: "表示用イベント種別",
+ eventDate: "文書日付",
+ responsibilityEvidence: "観察された担当根拠",
+ noResponsibilityEvidence: "このイベントには担当根拠が記録されていません。",
+ continuous: "担当継続",
+ handoff: "担当引継ぎ",
+ assignmentGap: "担当根拠の空白",
+ priorHistory: "関連する過去履歴",
+ noPriorHistory: "このイベントに至る可視の過去系譜はありません。",
+ inferredBoundary: "これは推論された関連履歴であり、因果関係や権威ある配属記録ではありません。",
+ projectEvidence: "プロジェクト識別根拠",
+ observed: "観察済み",
+ inferred: "推論済み",
+ openSourceRecord: "原資料を開く: {title}",
+ exactValues: "正確な値",
+ exactTableLabel: "プロジェクト履歴の正確な値",
+ columnDate: "日付",
+ columnEvent: "イベント",
+ columnType: "種別",
+ columnTransition: "担当変化",
+ columnActors: "観察担当者",
+ columnPathScore: "最小系譜スコア",
+ notApplicable: "該当なし",
+ contractAwarded: "受注確定",
+ specificationChanged: "仕様変更",
+ delivered: "納品",
+ handoffRecorded: "引継ぎ記録",
+ vocReceived: "VOC受付",
+ rebidStarted: "再入札開始",
+ sourceRecorded: "原資料",
+ openProjectHistory: "プロジェクト履歴を開く",
+ historyUnavailable: "この根拠のプロジェクト履歴は利用できません。",
+ },
+ vi: {
+ heading: "Dòng thời gian sự kiện dự án",
+ summaryCounts: "{events} sự kiện · {actors} người phụ trách được quan sát",
+ documentTime: "Ngày dùng thời gian tài liệu, không khẳng định là thời điểm sự kiện thực tế.",
+ truncated: "Dòng thời gian có giới hạn này đã bị rút gọn nhưng vẫn giữ sự kiện đang chọn.",
+ eventDetail: "Chi tiết sự kiện",
+ eventType: "Loại sự kiện hiển thị",
+ eventDate: "Ngày tài liệu",
+ responsibilityEvidence: "Bằng chứng trách nhiệm quan sát được",
+ noResponsibilityEvidence: "Không có bằng chứng trách nhiệm được ghi cho sự kiện này.",
+ continuous: "Trách nhiệm được duy trì",
+ handoff: "Bàn giao trách nhiệm",
+ assignmentGap: "Khoảng trống bằng chứng phân công",
+ priorHistory: "Lịch sử trước đó có liên quan",
+ noPriorHistory: "Không có đường dẫn lịch sử trước đó khả kiến cho sự kiện này.",
+ inferredBoundary: "Đây là lịch sử liên quan được suy luận, không phải quan hệ nhân quả hay hồ sơ phân công có thẩm quyền.",
+ projectEvidence: "Bằng chứng nhận dạng dự án",
+ observed: "Đã quan sát",
+ inferred: "Đã suy luận",
+ openSourceRecord: "Mở bản ghi nguồn: {title}",
+ exactValues: "Giá trị chính xác",
+ exactTableLabel: "Giá trị chính xác của lịch sử dự án",
+ columnDate: "Ngày",
+ columnEvent: "Sự kiện",
+ columnType: "Loại",
+ columnTransition: "Thay đổi trách nhiệm",
+ columnActors: "Người phụ trách được quan sát",
+ columnPathScore: "Điểm dòng dõi tối thiểu",
+ notApplicable: "Không áp dụng",
+ contractAwarded: "Đã trao hợp đồng",
+ specificationChanged: "Đã thay đổi đặc tả",
+ delivered: "Đã bàn giao sản phẩm",
+ handoffRecorded: "Đã ghi nhận bàn giao",
+ vocReceived: "Đã nhận ý kiến khách hàng",
+ rebidStarted: "Đã bắt đầu đấu thầu lại",
+ sourceRecorded: "Bản ghi nguồn",
+ openProjectHistory: "Mở lịch sử dự án",
+ historyUnavailable: "Lịch sử dự án không khả dụng cho bằng chứng này.",
+ },
+};
+
+export function projectHistoryText(
+ locale: Locale,
+ key: ProjectHistoryMessageKey,
+ params: MessageParams = {},
+): string {
+ let value = MESSAGES[locale][key];
+ for (const [name, replacement] of Object.entries(params)) {
+ value = value.replaceAll(`{${name}}`, String(replacement));
+ }
+ return value;
+}
+
+export function projectHistoryEventTypeLabel(locale: Locale, code: string): string {
+ const keyByCode: Record = {
+ contract_awarded: "contractAwarded",
+ specification_changed: "specificationChanged",
+ delivered: "delivered",
+ handoff_recorded: "handoffRecorded",
+ voc_received: "vocReceived",
+ rebid_started: "rebidStarted",
+ source_recorded: "sourceRecorded",
+ };
+ const key = keyByCode[code];
+ return key ? projectHistoryText(locale, key) : code;
+}
+
+export function projectHistoryTransitionLabel(
+ locale: Locale,
+ code: ResponsibilityTransitionCode | null,
+): string {
+ if (code === "continuous") return projectHistoryText(locale, "continuous");
+ if (code === "handoff") return projectHistoryText(locale, "handoff");
+ if (code === "assignment_gap") return projectHistoryText(locale, "assignmentGap");
+ return projectHistoryText(locale, "notApplicable");
+}
diff --git a/lineageweave/project_history.py b/lineageweave/project_history.py
new file mode 100644
index 000000000..b594c3d0f
--- /dev/null
+++ b/lineageweave/project_history.py
@@ -0,0 +1,396 @@
+"""Build evidence-bound project histories from already-authorized rows.
+
+The module is deliberately storage-agnostic. Callers must apply RBAC, ABAC,
+source eligibility, and knowledge-cutoff filtering before invoking it. It then
+orders visible source records, keeps explicit and semantic project matches
+separate, projects observed responsibility evidence, and explains persisted
+lineage paths without promoting them to causal or authoritative facts.
+"""
+
+from __future__ import annotations
+
+from collections import deque
+from collections.abc import Mapping, Sequence
+from datetime import datetime, timezone
+from decimal import Decimal
+from typing import Any
+from unicodedata import normalize
+
+PROJECT_HISTORY_CONTRACT_VERSION = 1
+PROJECT_HISTORY_TIME_BASIS = "document_time"
+PROJECT_HISTORY_MAX_DEPTH = 8
+PROJECT_HISTORY_MAX_PATHS_PER_EVENT = 32
+
+_EVENT_PATTERNS: tuple[tuple[str, tuple[str, ...]], ...] = (
+ ("rebid_started", ("rebid", "re-bid", "retender", "re-tender", "재입찰")),
+ (
+ "handoff_recorded",
+ ("handoff", "hand-off", "transferred ownership", "operational transfer", "인수인계"),
+ ),
+ (
+ "specification_changed",
+ (
+ "specification change",
+ "specification revision",
+ "revised specification",
+ "spec revision",
+ "사양 변경",
+ "사양변경",
+ ),
+ ),
+ (
+ "delivered",
+ (
+ "delivery confirmed",
+ "delivery completed",
+ "delivered",
+ "shipment completed",
+ "납품 완료",
+ "납품완료",
+ ),
+ ),
+ (
+ "contract_awarded",
+ (
+ "contract awarded",
+ "award confirmed",
+ "order confirmation",
+ "purchase order received",
+ "수주 확정",
+ "수주확정",
+ ),
+ ),
+)
+_VOC_CODES = frozenset({"voc", "vocc", "voco", "vom", "vop"})
+
+
+def normalize_project_key(value: str) -> str:
+ """Return the exact project-identity comparison key.
+
+ Compatibility normalization lets full-width and compatibility forms match
+ while preserving a deterministic, locale-neutral lower-case comparison.
+ Empty values are rejected rather than becoming a match-all key.
+ """
+
+ normalized = normalize("NFKC", value).strip().lower()
+ if not normalized:
+ raise ValueError("project key must not be empty")
+ if len(normalized.encode("utf-8")) > 256:
+ raise ValueError("project key exceeds 256 UTF-8 bytes")
+ return normalized
+
+
+def classify_project_event(
+ *,
+ title: str,
+ source_stage_code: str | None,
+ source_detail_state_code: str | None,
+ voc_type_code: str | None,
+ is_focus: bool,
+) -> str:
+ """Classify a display event from explicit source text and codes.
+
+ The code is presentation metadata only. It never creates a new event or
+ changes the truth status of the source record.
+ """
+
+ text = " ".join(
+ part.strip().lower()
+ for part in (title, source_stage_code or "", source_detail_state_code or "")
+ if part.strip()
+ )
+ for event_code, patterns in _EVENT_PATTERNS:
+ if any(pattern in text for pattern in patterns):
+ return event_code
+ if is_focus and (voc_type_code or "").strip().lower() in _VOC_CODES:
+ return "voc_received"
+ return "source_recorded"
+
+
+def responsibility_transition_code(
+ previous_actor_keys: Sequence[str], current_actor_keys: Sequence[str]
+) -> str:
+ """Classify adjacent observed responsibility evidence.
+
+ Missing evidence on either event is an ``assignment_gap``. Equal non-empty
+ actor sets are ``continuous``; different non-empty sets are ``handoff``.
+ The result describes document evidence, not an HR assignment fact.
+ """
+
+ previous = frozenset(key for key in previous_actor_keys if key)
+ current = frozenset(key for key in current_actor_keys if key)
+ if not previous or not current:
+ return "assignment_gap"
+ if previous == current:
+ return "continuous"
+ return "handoff"
+
+
+def _as_utc(value: datetime) -> str:
+ """Serialize a datetime as canonical UTC RFC 3339 text."""
+
+ aware = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
+ return aware.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def _actor_key(role: Mapping[str, Any]) -> str:
+ """Return a stable key for one observed R&R actor."""
+
+ catalog_fields = (
+ ("person", role.get("cataloged_person_id")),
+ ("team", role.get("cataloged_team_id")),
+ ("organization", role.get("cataloged_corporate_entity_id")),
+ )
+ for prefix, value in catalog_fields:
+ if value:
+ return f"{prefix}:{value}"
+ parts = (
+ str(role.get("actor_type_code") or "unknown"),
+ str(role.get("actor_name") or ""),
+ str(role.get("affiliated_organization_name") or ""),
+ )
+ return "text:" + "\u001f".join(normalize("NFKC", part).strip().lower() for part in parts)
+
+
+def _score(value: object) -> float:
+ """Return a finite JSON-compatible lineage score."""
+
+ if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)):
+ raise ValueError("lineage score must be numeric")
+ result = float(value)
+ if result != result or result in (float("inf"), float("-inf")):
+ raise ValueError("lineage score must be finite")
+ return result
+
+
+def _prior_paths(
+ ordered_event_ids: Sequence[str],
+ edge_rows: Sequence[Mapping[str, Any]],
+ *,
+ maximum_depth: int,
+ maximum_paths_per_event: int,
+) -> dict[str, list[dict[str, Any]]]:
+ """Return one deterministic shortest visible path per prior event."""
+
+ event_index = {event_id: index for index, event_id in enumerate(ordered_event_ids)}
+ reverse_edges: dict[str, list[dict[str, Any]]] = {event_id: [] for event_id in ordered_event_ids}
+ for row in edge_rows:
+ parent = str(row["parent_post_id"])
+ child = str(row["child_post_id"])
+ if parent not in event_index or child not in event_index:
+ continue
+ if event_index[parent] >= event_index[child]:
+ continue
+ reverse_edges[child].append(
+ {
+ "parent_event_id": parent,
+ "child_event_id": child,
+ "fused_score": _score(row["fused_score"]),
+ }
+ )
+ for edges in reverse_edges.values():
+ edges.sort(key=lambda edge: (event_index[edge["parent_event_id"]], edge["parent_event_id"]))
+
+ result: dict[str, list[dict[str, Any]]] = {}
+ for target in ordered_event_ids:
+ queue: deque[tuple[str, tuple[str, ...], tuple[dict[str, Any], ...]]] = deque(
+ [(target, (target,), ())]
+ )
+ best_depth = {target: 0}
+ paths: list[dict[str, Any]] = []
+ while queue and len(paths) < maximum_paths_per_event:
+ current, reverse_event_path, reverse_edge_path = queue.popleft()
+ depth = len(reverse_edge_path)
+ if depth >= maximum_depth:
+ continue
+ for edge in reverse_edges[current]:
+ parent = edge["parent_event_id"]
+ if parent in reverse_event_path:
+ continue
+ next_depth = depth + 1
+ if best_depth.get(parent, maximum_depth + 1) <= next_depth:
+ continue
+ best_depth[parent] = next_depth
+ next_events = reverse_event_path + (parent,)
+ next_edges = reverse_edge_path + (edge,)
+ ordered_events = list(reversed(next_events))
+ ordered_edges = list(reversed(next_edges))
+ paths.append(
+ {
+ "source_event_id": parent,
+ "target_event_id": target,
+ "event_ids": ordered_events,
+ "edges": ordered_edges,
+ "minimum_fused_score": min(item["fused_score"] for item in ordered_edges),
+ "truth_status_code": "inferred",
+ "source_relation_code": "post_lineage_edge",
+ "provenance": "post_lineage_edge.fused_score",
+ }
+ )
+ queue.append((parent, next_events, next_edges))
+ if len(paths) >= maximum_paths_per_event:
+ break
+ paths.sort(
+ key=lambda path: (
+ len(path["edges"]),
+ event_index[path["source_event_id"]],
+ tuple(path["event_ids"]),
+ )
+ )
+ result[target] = paths
+ return result
+
+
+def build_project_history_projection(
+ *,
+ project_key: str,
+ focus_event_id: str | None,
+ event_rows: Sequence[Mapping[str, Any]],
+ match_rows: Sequence[Mapping[str, Any]],
+ role_rows: Sequence[Mapping[str, Any]],
+ edge_rows: Sequence[Mapping[str, Any]],
+ truncated: bool = False,
+ maximum_depth: int = PROJECT_HISTORY_MAX_DEPTH,
+ maximum_paths_per_event: int = PROJECT_HISTORY_MAX_PATHS_PER_EVENT,
+) -> dict[str, Any]:
+ """Build the versioned Buyer project-history projection.
+
+ All input rows must already be visible, eligible, and within the requested
+ knowledge cutoff. Duplicate event rows are collapsed by ``post_id`` and the
+ final chronology is stable on ``(created_at, post_id)``.
+ """
+
+ normalized_key = normalize_project_key(project_key)
+ if maximum_depth < 1 or maximum_depth > PROJECT_HISTORY_MAX_DEPTH:
+ raise ValueError("maximum_depth is outside the supported bound")
+ if maximum_paths_per_event < 1 or maximum_paths_per_event > PROJECT_HISTORY_MAX_PATHS_PER_EVENT:
+ raise ValueError("maximum_paths_per_event is outside the supported bound")
+
+ deduplicated: dict[str, Mapping[str, Any]] = {}
+ for row in event_rows:
+ event_id = str(row["post_id"])
+ current = deduplicated.get(event_id)
+ if current is None or (row["created_at"], event_id) < (current["created_at"], event_id):
+ deduplicated[event_id] = row
+ ordered_rows = sorted(deduplicated.values(), key=lambda row: (row["created_at"], str(row["post_id"])))
+ if not ordered_rows:
+ raise ValueError("project history requires at least one visible event")
+ ordered_ids = [str(row["post_id"]) for row in ordered_rows]
+ effective_focus = focus_event_id or ordered_ids[-1]
+ if effective_focus not in set(ordered_ids):
+ raise ValueError("focus event is not in the visible project history")
+
+ matches_by_event: dict[str, list[dict[str, Any]]] = {event_id: [] for event_id in ordered_ids}
+ display_names: list[str] = []
+ seen_matches: set[tuple[str, str, str]] = set()
+ for row in match_rows:
+ event_id = str(row["post_id"])
+ if event_id not in matches_by_event:
+ continue
+ matched_value = str(row["matched_value"])
+ if normalize_project_key(matched_value) != normalized_key:
+ continue
+ kind = str(row["match_kind_code"])
+ key = (event_id, kind, matched_value)
+ if key in seen_matches:
+ continue
+ seen_matches.add(key)
+ confidence = row.get("confidence")
+ if confidence is not None:
+ confidence = _score(confidence)
+ truth = "observed" if kind.startswith("source_") else "inferred"
+ matches_by_event[event_id].append(
+ {
+ "match_kind_code": kind,
+ "matched_value": matched_value,
+ "truth_status_code": truth,
+ "confidence": confidence,
+ "ontology_iri": row.get("ontology_iri"),
+ "provenance": str(row["provenance"]),
+ }
+ )
+ if kind.endswith("name"):
+ display_names.append(matched_value)
+ for matches in matches_by_event.values():
+ matches.sort(key=lambda item: (item["truth_status_code"], item["match_kind_code"], item["matched_value"]))
+
+ roles_by_event: dict[str, list[dict[str, Any]]] = {event_id: [] for event_id in ordered_ids}
+ actor_keys_by_event: dict[str, list[str]] = {event_id: [] for event_id in ordered_ids}
+ distinct_actor_keys: set[str] = set()
+ for row in role_rows:
+ event_id = str(row["post_id"])
+ if event_id not in roles_by_event:
+ continue
+ actor_key = _actor_key(row)
+ distinct_actor_keys.add(actor_key)
+ actor_keys_by_event[event_id].append(actor_key)
+ roles_by_event[event_id].append(
+ {
+ "actor_key": actor_key,
+ "actor_name": str(row["actor_name"]),
+ "actor_type_code": str(row["actor_type_code"]),
+ "affiliated_organization_name": row.get("affiliated_organization_name"),
+ "responsibility": str(row["responsibility"]),
+ "truth_status_code": "observed",
+ "provenance": "post_summary_role",
+ }
+ )
+ for roles in roles_by_event.values():
+ roles.sort(key=lambda role: (role["actor_type_code"], role["actor_name"], role["actor_key"]))
+
+ paths_by_event = _prior_paths(
+ ordered_ids,
+ edge_rows,
+ maximum_depth=maximum_depth,
+ maximum_paths_per_event=maximum_paths_per_event,
+ )
+
+ events: list[dict[str, Any]] = []
+ previous_actor_keys: Sequence[str] | None = None
+ for row in ordered_rows:
+ event_id = str(row["post_id"])
+ current_actor_keys = actor_keys_by_event[event_id]
+ transition = (
+ None
+ if previous_actor_keys is None
+ else responsibility_transition_code(previous_actor_keys, current_actor_keys)
+ )
+ events.append(
+ {
+ "event_id": event_id,
+ "source_post_id": event_id,
+ "event_title": str(row["post_title"]),
+ "event_type_code": classify_project_event(
+ title=str(row["post_title"]),
+ source_stage_code=row.get("source_stage_code"),
+ source_detail_state_code=row.get("source_detail_state_code"),
+ voc_type_code=row.get("voc_type_code"),
+ is_focus=event_id == effective_focus,
+ ),
+ "event_type_basis_code": "display_classification",
+ "occurred_at": _as_utc(row["created_at"]),
+ "time_basis_code": PROJECT_HISTORY_TIME_BASIS,
+ "voc_type_code": row.get("voc_type_code"),
+ "source_stage_code": row.get("source_stage_code"),
+ "source_detail_state_code": row.get("source_detail_state_code"),
+ "project_matches": matches_by_event[event_id],
+ "observed_responsibilities": roles_by_event[event_id],
+ "responsibility_transition_code": transition,
+ "related_prior_paths": paths_by_event[event_id],
+ }
+ )
+ previous_actor_keys = current_actor_keys
+
+ project_name = display_names[0] if display_names else project_key.strip()
+ return {
+ "contract_version": PROJECT_HISTORY_CONTRACT_VERSION,
+ "project_key": project_key.strip(),
+ "normalized_project_key": normalized_key,
+ "project_name": project_name,
+ "focus_event_id": effective_focus,
+ "time_basis_code": PROJECT_HISTORY_TIME_BASIS,
+ "event_count": len(events),
+ "distinct_observed_actor_count": len(distinct_actor_keys),
+ "truncated": bool(truncated),
+ "events": events,
+ }
diff --git a/lineageweave/tepp_project_history.py b/lineageweave/tepp_project_history.py
new file mode 100644
index 000000000..4490bed9f
--- /dev/null
+++ b/lineageweave/tepp_project_history.py
@@ -0,0 +1,327 @@
+"""Strict LineageWeave client for TEPP project-history projections.
+
+LineageWeave selects authorized source evidence. TEPP validates the knowledge
+cutoff, orders explicit events, and returns coded temporal associations. This
+module never supplies provider credentials, never treats event order as
+causality, and never accepts a theta or an unpublished score field.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any, Callable
+
+from lineageweave.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_CONSUMER_CODE = "lineageweave"
+
+Transport = Callable[[dict[str, Any], dict[str, str]], dict[str, Any]]
+
+
+class TeppProjectHistoryNotAvailable(RuntimeError):
+ """TEPP project-history transport is absent or returned an unusable result."""
+
+
+def _parse_timestamp(value: object, field_name: str) -> datetime:
+ """Parse one timezone-aware RFC 3339-like timestamp or fail closed."""
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError(f"{field_name} must be a non-empty timestamp")
+ try:
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError as exc:
+ raise ValueError(f"{field_name} must be an RFC 3339 timestamp") from exc
+ if parsed.tzinfo is None:
+ raise ValueError(f"{field_name} must include an offset")
+ return parsed
+
+
+def _require_exact_keys(payload: dict[str, Any], expected: frozenset[str], name: str) -> None:
+ """Reject missing or unpublished fields in a versioned TEPP envelope."""
+ actual = frozenset(payload)
+ if actual != expected:
+ raise ValueError(f"invalid {name} fields")
+
+
+def _require_text(value: object, field_name: str, maximum: int = 4096) -> str:
+ """Return bounded non-empty text from an untrusted wire value."""
+ if not isinstance(value, str) or not value.strip() or len(value.encode("utf-8")) > maximum:
+ raise ValueError(f"{field_name} must be bounded non-empty text")
+ return value
+
+
+@dataclass(frozen=True)
+class ProjectHistoryEvent:
+ """One explicit, source-grounded event sent to or returned by TEPP."""
+
+ event_id: str
+ event_type_code: str
+ event_title: str
+ occurred_at: str
+ available_at: str
+ availability_basis_code: str
+ source_post_id: str
+ evidence_text: str
+ actor_ids: tuple[str, ...] = ()
+
+ def to_json(self) -> dict[str, Any]:
+ """Serialize this event without post bodies or identity labels."""
+ return {
+ "event_id": self.event_id,
+ "event_type_code": self.event_type_code,
+ "event_title": self.event_title,
+ "occurred_at": self.occurred_at,
+ "available_at": self.available_at,
+ "availability_basis_code": self.availability_basis_code,
+ "source_post_id": self.source_post_id,
+ "evidence_text": self.evidence_text,
+ "actor_ids": list(self.actor_ids),
+ }
+
+ @classmethod
+ def from_json(cls, payload: object) -> ProjectHistoryEvent:
+ """Parse one strict TEPP event from an untrusted JSON object."""
+ if not isinstance(payload, dict):
+ raise ValueError("project-history event must be an object")
+ expected = frozenset(
+ {
+ "event_id",
+ "event_type_code",
+ "event_title",
+ "occurred_at",
+ "available_at",
+ "availability_basis_code",
+ "source_post_id",
+ "evidence_text",
+ "actor_ids",
+ }
+ )
+ _require_exact_keys(payload, expected, "project-history event")
+ actor_ids = payload["actor_ids"]
+ if not isinstance(actor_ids, list) or len(actor_ids) > 64:
+ raise ValueError("actor_ids must be a bounded list")
+ parsed_actor_ids = tuple(_require_text(value, "actor_id", 256) for value in actor_ids)
+ occurred_at = _require_text(payload["occurred_at"], "occurred_at", 64)
+ available_at = _require_text(payload["available_at"], "available_at", 64)
+ _parse_timestamp(occurred_at, "occurred_at")
+ _parse_timestamp(available_at, "available_at")
+ return cls(
+ event_id=_require_text(payload["event_id"], "event_id", 256),
+ event_type_code=_require_text(payload["event_type_code"], "event_type_code", 64),
+ event_title=_require_text(payload["event_title"], "event_title", 512),
+ occurred_at=occurred_at,
+ available_at=available_at,
+ availability_basis_code=_require_text(
+ payload["availability_basis_code"], "availability_basis_code", 64
+ ),
+ source_post_id=_require_text(payload["source_post_id"], "source_post_id", 256),
+ evidence_text=_require_text(payload["evidence_text"], "evidence_text"),
+ actor_ids=parsed_actor_ids,
+ )
+
+
+@dataclass(frozen=True)
+class ProjectHistoryRequest:
+ """Versioned TEPP request built only from authorized project evidence."""
+
+ contract_version: int
+ idempotency_key: str
+ tenant_workspace_id: str
+ project_key: str
+ project_name: str
+ knowledge_cutoff: str
+ focus_event_id: str
+ events: tuple[ProjectHistoryEvent, ...]
+
+ def to_json(self) -> dict[str, Any]:
+ """Serialize the exact public TEPP request contract."""
+ return {
+ "contract_version": self.contract_version,
+ "idempotency_key": self.idempotency_key,
+ "tenant_workspace_id": self.tenant_workspace_id,
+ "project_key": self.project_key,
+ "project_name": self.project_name,
+ "knowledge_cutoff": self.knowledge_cutoff,
+ "focus_event_id": self.focus_event_id,
+ "events": [event.to_json() for event in self.events],
+ }
+
+
+@dataclass(frozen=True)
+class ProjectHistoryFinding:
+ """One TEPP-coded temporal association and its source evidence."""
+
+ finding_code: str
+ summary: str
+ related_event_ids: tuple[str, ...]
+ evidence_post_ids: tuple[str, ...]
+
+ @classmethod
+ def from_json(cls, payload: object) -> ProjectHistoryFinding:
+ """Parse one strict temporal finding."""
+ if not isinstance(payload, dict):
+ raise ValueError("project-history finding must be an object")
+ expected = frozenset(
+ {"finding_code", "summary", "related_event_ids", "evidence_post_ids"}
+ )
+ _require_exact_keys(payload, expected, "project-history finding")
+ related = payload["related_event_ids"]
+ evidence = payload["evidence_post_ids"]
+ if not isinstance(related, list) or not isinstance(evidence, list) or not evidence:
+ raise ValueError("project-history finding must name its evidence")
+ return cls(
+ finding_code=_require_text(payload["finding_code"], "finding_code", 128),
+ summary=_require_text(payload["summary"], "summary"),
+ related_event_ids=tuple(_require_text(value, "related_event_id", 256) for value in related),
+ evidence_post_ids=tuple(_require_text(value, "evidence_post_id", 256) for value in evidence),
+ )
+
+
+@dataclass(frozen=True)
+class ProjectHistoryProjection:
+ """Validated TEPP response rendered by LineageWeave buyer surfaces."""
+
+ contract_version: int
+ project_key: str
+ project_name: str
+ focus_event_id: str
+ history_span_start: str
+ history_span_end: str
+ participant_count: int
+ inference_status: str
+ events: tuple[ProjectHistoryEvent, ...]
+ findings: tuple[ProjectHistoryFinding, ...]
+
+ @classmethod
+ def from_json(cls, payload: object) -> ProjectHistoryProjection:
+ """Parse and validate the complete public TEPP projection."""
+ if not isinstance(payload, dict):
+ raise ValueError("project-history projection must be an object")
+ expected = frozenset(
+ {
+ "contract_version",
+ "project_key",
+ "project_name",
+ "focus_event_id",
+ "history_span_start",
+ "history_span_end",
+ "participant_count",
+ "inference_status",
+ "events",
+ "findings",
+ }
+ )
+ _require_exact_keys(payload, expected, "project-history projection")
+ if payload["contract_version"] != PROJECT_HISTORY_CONTRACT_VERSION:
+ raise ValueError("unsupported project-history contract version")
+ if payload["inference_status"] != PROJECT_HISTORY_INFERENCE_STATUS:
+ raise ValueError("project-history projection must remain non-causal")
+ participant_count = payload["participant_count"]
+ if isinstance(participant_count, bool) or not isinstance(participant_count, int) or participant_count < 0:
+ raise ValueError("participant_count must be a non-negative integer")
+ raw_events = payload["events"]
+ raw_findings = payload["findings"]
+ if not isinstance(raw_events, list) or not raw_events or not isinstance(raw_findings, list):
+ raise ValueError("project-history projection requires event and finding lists")
+ events = tuple(ProjectHistoryEvent.from_json(event) for event in raw_events)
+ findings = tuple(ProjectHistoryFinding.from_json(finding) for finding in raw_findings)
+ event_ids = [event.event_id for event in events]
+ if len(event_ids) != len(set(event_ids)):
+ raise ValueError("project-history projection contains duplicate events")
+ focus_event_id = _require_text(payload["focus_event_id"], "focus_event_id", 256)
+ if focus_event_id not in set(event_ids):
+ raise ValueError("project-history focus event is absent")
+ occurred = [_parse_timestamp(event.occurred_at, "occurred_at") for event in events]
+ if occurred != sorted(occurred):
+ raise ValueError("project-history events are not ordered")
+ history_span_start = _require_text(payload["history_span_start"], "history_span_start", 64)
+ history_span_end = _require_text(payload["history_span_end"], "history_span_end", 64)
+ if _parse_timestamp(history_span_start, "history_span_start") > _parse_timestamp(
+ history_span_end, "history_span_end"
+ ):
+ raise ValueError("project-history span is inverted")
+ return cls(
+ contract_version=PROJECT_HISTORY_CONTRACT_VERSION,
+ project_key=_require_text(payload["project_key"], "project_key", 256),
+ project_name=_require_text(payload["project_name"], "project_name", 512),
+ focus_event_id=focus_event_id,
+ history_span_start=history_span_start,
+ history_span_end=history_span_end,
+ participant_count=participant_count,
+ inference_status=PROJECT_HISTORY_INFERENCE_STATUS,
+ events=events,
+ findings=findings,
+ )
+
+ def to_json(self) -> dict[str, Any]:
+ """Serialize the validated projection for the API and frontend."""
+ return {
+ "contract_version": self.contract_version,
+ "project_key": self.project_key,
+ "project_name": self.project_name,
+ "focus_event_id": self.focus_event_id,
+ "history_span_start": self.history_span_start,
+ "history_span_end": self.history_span_end,
+ "participant_count": self.participant_count,
+ "inference_status": self.inference_status,
+ "events": [event.to_json() for event in self.events],
+ "findings": [
+ {
+ "finding_code": finding.finding_code,
+ "summary": finding.summary,
+ "related_event_ids": list(finding.related_event_ids),
+ "evidence_post_ids": list(finding.evidence_post_ids),
+ }
+ for finding in self.findings
+ ],
+ }
+
+
+def _no_transport(_payload: dict[str, Any], _headers: dict[str, str]) -> dict[str, Any]:
+ """Fail closed when no TEPP project-history endpoint is configured."""
+ raise TeppProjectHistoryNotAvailable("TEPP project-history transport is not configured")
+
+
+class TeppProjectHistoryClient:
+ """Submit strict project-history requests through a replaceable transport."""
+
+ def __init__(self, transport: Transport = _no_transport) -> None:
+ self._transport = transport
+
+ @property
+ def available(self) -> bool:
+ """Return whether this client has a configured transport."""
+ return self._transport is not _no_transport
+
+ def project(self, request: ProjectHistoryRequest) -> ProjectHistoryProjection:
+ """Submit a request and validate TEPP's exact non-causal response."""
+ headers = {
+ "tepp-consumer": PROJECT_HISTORY_CONSUMER_CODE,
+ "tepp-contract-version": str(PROJECT_HISTORY_CONTRACT_VERSION),
+ "idempotency-key": request.idempotency_key,
+ }
+ try:
+ payload = self._transport(request.to_json(), headers)
+ except TeppProjectHistoryNotAvailable:
+ raise
+ except (HttpClientError, OSError, TypeError, ValueError) as exc:
+ raise TeppProjectHistoryNotAvailable(str(exc)) from exc
+ return ProjectHistoryProjection.from_json(payload)
+
+
+def configured_tepp_project_history_client(url: str) -> TeppProjectHistoryClient:
+ """Build an HTTP TEPP client from an exact project-history endpoint URL."""
+ target = url.strip()
+ if not target:
+ return TeppProjectHistoryClient()
+
+ def transport(payload: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]:
+ try:
+ return post_json(target, payload, headers=headers, timeout=30.0)
+ except (HttpClientError, OSError, TypeError, ValueError) as exc:
+ raise TeppProjectHistoryNotAvailable(str(exc)) from exc
+
+ return TeppProjectHistoryClient(transport=transport)
diff --git a/migrations/0053_project_history_lookup.sql b/migrations/0053_project_history_lookup.sql
new file mode 100644
index 000000000..92ca0a3bb
--- /dev/null
+++ b/migrations/0053_project_history_lookup.sql
@@ -0,0 +1,37 @@
+begin;
+
+-- Exact NFKC/lower lookup keys keep explicit and semantic project evidence
+-- indexable without changing the underlying source or inference truth status.
+create index if not exists source_post_project_code_history_idx
+ on source_post (
+ lower(normalize(btrim(source_project_code), NFKC)),
+ created_at,
+ post_id
+ )
+ where source_project_code is not null and btrim(source_project_code) <> '';
+
+create index if not exists source_post_project_name_history_idx
+ on source_post (
+ lower(normalize(btrim(source_project_name), NFKC)),
+ created_at,
+ post_id
+ )
+ where source_project_name is not null and btrim(source_project_name) <> '';
+
+create index if not exists post_project_mention_key_history_idx
+ on post_project_mention (
+ lower(normalize(btrim(project_key), NFKC)),
+ post_id
+ );
+
+create index if not exists post_project_mention_name_history_idx
+ on post_project_mention (
+ lower(normalize(btrim(project_name), NFKC)),
+ post_id
+ );
+
+create index if not exists post_lineage_edge_child_history_idx
+ on post_lineage_edge (child_post_id, parent_post_id)
+ include (fused_score);
+
+commit;
diff --git a/migrations/rollback/0053_project_history_lookup.sql b/migrations/rollback/0053_project_history_lookup.sql
new file mode 100644
index 000000000..99de4c084
--- /dev/null
+++ b/migrations/rollback/0053_project_history_lookup.sql
@@ -0,0 +1,9 @@
+begin;
+
+drop index if exists post_lineage_edge_child_history_idx;
+drop index if exists post_project_mention_name_history_idx;
+drop index if exists post_project_mention_key_history_idx;
+drop index if exists source_post_project_name_history_idx;
+drop index if exists source_post_project_code_history_idx;
+
+commit;
diff --git a/tests/test_project_history_api.py b/tests/test_project_history_api.py
new file mode 100644
index 000000000..7c4ba4094
--- /dev/null
+++ b/tests/test_project_history_api.py
@@ -0,0 +1,171 @@
+"""The project-history HTTP contract is authorized, bounded, and non-leaking."""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID
+
+from fastapi import HTTPException
+import pytest
+
+from backend.app.auth import CurrentAccount
+from backend.app import project_history_api as api
+from backend.app.project_history import ProjectHistoryNotFound
+
+
+class _Acquire:
+ """Minimal asynchronous pool acquisition context."""
+
+ def __init__(self, connection: object) -> None:
+ self.connection = connection
+
+ async def __aenter__(self) -> object:
+ return self.connection
+
+ async def __aexit__(self, *args: object) -> None:
+ return None
+
+
+class _Pool:
+ """Record whether the endpoint acquired a database connection."""
+
+ def __init__(self) -> None:
+ self.connection = object()
+ self.acquired = False
+
+ def acquire(self) -> _Acquire:
+ """Return one asynchronous acquisition context."""
+
+ self.acquired = True
+ return _Acquire(self.connection)
+
+
+def _account(*permissions: str) -> CurrentAccount:
+ """Return one provisioned account with a deterministic ABAC scope."""
+
+ return CurrentAccount(
+ user_account_id="account-1",
+ external_subject_id="subject-1",
+ display_name="Buyer",
+ preferred_locale="en",
+ corporate_entity_ids=frozenset({"corp-1"}),
+ permission_codes=frozenset(permissions),
+ )
+
+
+def test_endpoint_rejects_missing_permission_before_database_access() -> None:
+ """A valid token without post_read cannot probe project existence."""
+
+ pool = _Pool()
+ with pytest.raises(HTTPException) as captured:
+ asyncio.run(
+ api.read_project_history(
+ project_key="P-100",
+ focus_post_id=None,
+ knowledge_cutoff=None,
+ limit=64,
+ account=_account(),
+ pool=pool, # type: ignore[arg-type]
+ )
+ )
+ assert captured.value.status_code == 403
+ assert pool.acquired is False
+
+
+def test_endpoint_rejects_invalid_cutoff_before_database_access() -> None:
+ """Malformed cutoff text fails without issuing an evidence query."""
+
+ pool = _Pool()
+ with pytest.raises(HTTPException) as captured:
+ asyncio.run(
+ api.read_project_history(
+ project_key="P-100",
+ focus_post_id=None,
+ knowledge_cutoff="not-a-clock",
+ limit=64,
+ account=_account("post_read"),
+ pool=pool, # type: ignore[arg-type]
+ )
+ )
+ assert captured.value.status_code == 422
+ assert pool.acquired is False
+
+
+def test_endpoint_maps_hidden_and_missing_history_to_the_same_404(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The response never distinguishes absent project evidence from hidden evidence."""
+
+ async def missing(*args: object, **kwargs: object) -> dict[str, Any]:
+ raise ProjectHistoryNotFound("P-100")
+
+ monkeypatch.setattr(api, "fetch_project_history_projection", missing)
+ with pytest.raises(HTTPException) as captured:
+ asyncio.run(
+ api.read_project_history(
+ project_key="P-100",
+ focus_post_id=UUID("00000000-0000-0000-0000-000000000100"),
+ knowledge_cutoff="2026-01-31T23:59:59Z",
+ limit=64,
+ account=_account("post_read"),
+ pool=_Pool(), # type: ignore[arg-type]
+ )
+ )
+ assert captured.value.status_code == 404
+ assert captured.value.detail == "project history not found"
+
+
+def test_endpoint_passes_exact_scope_cutoff_focus_and_limit(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The repository receives only the authenticated scope and parsed clock."""
+
+ captured: dict[str, object] = {}
+ expected = {
+ "contract_version": 1,
+ "project_key": "P-100",
+ "normalized_project_key": "p-100",
+ "project_name": "Project 100",
+ "focus_event_id": "00000000-0000-0000-0000-000000000100",
+ "time_basis_code": "document_time",
+ "event_count": 0,
+ "distinct_observed_actor_count": 0,
+ "truncated": False,
+ "events": [],
+ }
+
+ async def found(connection: object, **kwargs: object) -> dict[str, Any]:
+ captured["connection"] = connection
+ captured.update(kwargs)
+ return expected
+
+ monkeypatch.setattr(api, "fetch_project_history_projection", found)
+ pool = _Pool()
+ result = asyncio.run(
+ api.read_project_history(
+ project_key="P-100",
+ focus_post_id=UUID("00000000-0000-0000-0000-000000000100"),
+ knowledge_cutoff="2026-01-31T23:59:59Z",
+ limit=32,
+ account=_account("post_read"),
+ pool=pool, # type: ignore[arg-type]
+ )
+ )
+
+ assert result == expected
+ assert captured["connection"] is pool.connection
+ assert captured["project_key"] == "P-100"
+ assert captured["focus_post_id"] == "00000000-0000-0000-0000-000000000100"
+ assert captured["knowledge_cutoff"] == datetime(
+ 2026,
+ 1,
+ 31,
+ 23,
+ 59,
+ 59,
+ tzinfo=timezone.utc,
+ )
+ assert captured["corporate_entity_ids"] == ["corp-1"]
+ assert captured["limit"] == 32
diff --git a/tests/test_project_history_migration.py b/tests/test_project_history_migration.py
new file mode 100644
index 000000000..bbf0afd49
--- /dev/null
+++ b/tests/test_project_history_migration.py
@@ -0,0 +1,34 @@
+"""Project-history indexes are reversible and cover every exact match key."""
+
+from pathlib import Path
+
+
+_ROOT = Path(__file__).resolve().parents[1]
+_MIGRATION = _ROOT / "migrations" / "0053_project_history_lookup.sql"
+_ROLLBACK = _ROOT / "migrations" / "rollback" / "0053_project_history_lookup.sql"
+
+
+def test_project_history_migration_indexes_explicit_and_semantic_keys() -> None:
+ """Every exact project-identity read has a normalized lookup index."""
+
+ sql = _MIGRATION.read_text(encoding="utf-8")
+ assert "source_post_project_code_history_idx" in sql
+ assert "source_post_project_name_history_idx" in sql
+ assert "post_project_mention_key_history_idx" in sql
+ assert "post_project_mention_name_history_idx" in sql
+ assert "post_lineage_edge_child_history_idx" in sql
+ assert sql.count("normalize(") >= 4
+
+
+def test_project_history_migration_has_a_complete_idempotent_rollback() -> None:
+ """The additive index migration can be rolled back without guessing."""
+
+ sql = _ROLLBACK.read_text(encoding="utf-8").lower()
+ for index_name in (
+ "post_lineage_edge_child_history_idx",
+ "post_project_mention_name_history_idx",
+ "post_project_mention_key_history_idx",
+ "source_post_project_name_history_idx",
+ "source_post_project_code_history_idx",
+ ):
+ assert f"drop index if exists {index_name}" in sql
diff --git a/tests/test_project_history_postgres.py b/tests/test_project_history_postgres.py
new file mode 100644
index 000000000..66062a65a
--- /dev/null
+++ b/tests/test_project_history_postgres.py
@@ -0,0 +1,383 @@
+"""Real-PostgreSQL proof that hidden records cannot influence project history."""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime
+import os
+from pathlib import Path
+from urllib.parse import urlsplit, urlunsplit
+import uuid
+
+import asyncpg
+import psycopg2
+from psycopg2 import sql
+import pytest
+
+from backend.app.project_history import fetch_project_history_projection
+
+
+_ADMIN_DSN = os.environ.get(
+ "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres"
+)
+_ROOT = Path(__file__).resolve().parents[1]
+_MIGRATIONS = tuple(
+ _ROOT / "migrations" / name
+ for name in (
+ "0001_initial_schema.sql",
+ "0031_semantic_project_mentions.sql",
+ "0033_source_state_provenance.sql",
+ "0034_source_context_provenance.sql",
+ "0038_source_named_hints.sql",
+ "0039_source_org_named_hints.sql",
+ "0053_project_history_lookup.sql",
+ )
+)
+
+
+def _postgres_available() -> bool:
+ """Return whether the configured PostgreSQL service accepts connections."""
+
+ try:
+ psycopg2.connect(_ADMIN_DSN, connect_timeout=2).close()
+ return True
+ except psycopg2.OperationalError:
+ return False
+
+
+pytestmark = pytest.mark.skipif(
+ not _postgres_available(),
+ reason=f"no reachable PostgreSQL server at {_ADMIN_DSN}",
+)
+
+
+def _database_dsn(database_name: str) -> str:
+ """Replace the DSN database path while preserving connection options."""
+
+ parsed = urlsplit(_ADMIN_DSN)
+ return urlunsplit(parsed._replace(path=f"/{database_name}"))
+
+
+@pytest.fixture
+def project_history_database() -> tuple[str, str]:
+ """Create a migrated database with visible, hidden, and excluded evidence."""
+
+ database_name = f"lineageweave_project_history_{uuid.uuid4().hex[:12]}"
+ admin = psycopg2.connect(_ADMIN_DSN)
+ admin.autocommit = True
+ with admin.cursor() as cursor:
+ cursor.execute(sql.SQL("create database {}").format(sql.Identifier(database_name)))
+ database_dsn = _database_dsn(database_name)
+ connection = psycopg2.connect(database_dsn)
+ try:
+ with connection.cursor() as cursor:
+ for migration in _MIGRATIONS:
+ cursor.execute(migration.read_text(encoding="utf-8"))
+ cursor.execute(
+ """
+ insert into common_lookup_value
+ (lookup_category, lookup_code, lookup_label)
+ values
+ ('corporate_entity_level', 'company', 'Company'),
+ ('post_visibility', 'public', 'Public'),
+ ('post_visibility', 'private', 'Private'),
+ ('voc_type', 'voc', 'Voice of Customer'),
+ ('voc_type', 'vom', 'Voice of Market'),
+ ('person_side', 'our_side', 'Our side'),
+ ('prov_agent_type', 'prov_person', 'Person'),
+ ('prov_agent_type', 'prov_organization', 'Organization'),
+ ('prov_agent_type', 'prov_team', 'Team')
+ on conflict (lookup_code) do nothing
+ """
+ )
+ cursor.execute(
+ """
+ insert into corporate_entity
+ (corporate_entity_code, entity_name, entity_level_code)
+ values ('OWN-CORP', 'Own Corp', 'company')
+ returning corporate_entity_id
+ """
+ )
+ own_corporate_entity_id = cursor.fetchone()[0]
+ cursor.execute(
+ """
+ insert into corporate_entity
+ (corporate_entity_code, entity_name, entity_level_code)
+ values ('OTHER-CORP', 'Other Corp', 'company')
+ returning corporate_entity_id
+ """
+ )
+ other_corporate_entity_id = cursor.fetchone()[0]
+ cursor.execute(
+ """
+ insert into user_account
+ (external_subject_id, display_name, email_address)
+ values ('history-user', 'History User', 'history@example.test')
+ returning user_account_id
+ """
+ )
+ account_id = cursor.fetchone()[0]
+
+ post_ids: dict[str, str] = {}
+ rows = (
+ (
+ "award",
+ own_corporate_entity_id,
+ "public",
+ "Contract awarded",
+ "vom",
+ "P-100",
+ None,
+ None,
+ "2026-01-01T09:00:00Z",
+ ),
+ (
+ "spec",
+ own_corporate_entity_id,
+ "private",
+ "Specification revision requested",
+ "vom",
+ "P-100",
+ None,
+ None,
+ "2026-01-02T09:00:00Z",
+ ),
+ (
+ "delivery",
+ own_corporate_entity_id,
+ "public",
+ "Delivery confirmed",
+ "vom",
+ None,
+ None,
+ None,
+ "2026-01-03T09:00:00Z",
+ ),
+ (
+ "voc",
+ own_corporate_entity_id,
+ "public",
+ "VOC received",
+ "voc",
+ "P-100",
+ None,
+ None,
+ "2026-01-04T09:00:00Z",
+ ),
+ (
+ "hidden",
+ other_corporate_entity_id,
+ "private",
+ "Hidden handoff",
+ "vom",
+ "P-100",
+ None,
+ None,
+ "2026-01-03T12:00:00Z",
+ ),
+ (
+ "draft",
+ own_corporate_entity_id,
+ "public",
+ "Draft rebid",
+ "vom",
+ "P-100",
+ "draft",
+ None,
+ "2026-01-05T09:00:00Z",
+ ),
+ (
+ "deleted",
+ own_corporate_entity_id,
+ "public",
+ "Deleted rebid",
+ "vom",
+ "P-100",
+ None,
+ "deleted",
+ "2026-01-05T10:00:00Z",
+ ),
+ (
+ "future",
+ own_corporate_entity_id,
+ "public",
+ "Future rebid",
+ "vom",
+ "P-100",
+ None,
+ None,
+ "2026-02-01T09:00:00Z",
+ ),
+ )
+ for (
+ key,
+ corporate_id,
+ visibility,
+ title,
+ voc,
+ project_code,
+ draft,
+ deleted,
+ created_at,
+ ) in rows:
+ cursor.execute(
+ """
+ insert into source_post
+ (author_account_id, corporate_entity_id, post_title, post_body,
+ voc_type_code, visibility_code, source_project_code,
+ source_project_name, source_draft_code, source_deleted_flag,
+ created_at, updated_at)
+ values (%s, %s, %s, 'Synthetic project evidence', %s, %s,
+ %s, 'Northridge renewal', %s, %s, %s, %s)
+ returning post_id
+ """,
+ (
+ account_id,
+ corporate_id,
+ title,
+ voc,
+ visibility,
+ project_code,
+ draft,
+ deleted,
+ created_at,
+ created_at,
+ ),
+ )
+ post_ids[key] = str(cursor.fetchone()[0])
+
+ cursor.execute(
+ """
+ insert into post_project_mention
+ (post_id, project_key, project_name, evidence_text,
+ confidence, ontology_iri, extraction_method)
+ values
+ (%s, 'P-100', 'Northridge renewal',
+ 'The delivered project was identified semantically.', 0.910,
+ 'https://w3id.org/lineageweave#Project',
+ 'contextual_orchestrator_semantic'),
+ (%s, 'P-100', 'Northridge renewal',
+ 'The awarded project also has semantic evidence.', 0.990,
+ 'https://w3id.org/lineageweave#Project',
+ 'contextual_orchestrator_semantic')
+ """,
+ (post_ids["delivery"], post_ids["award"]),
+ )
+
+ people: dict[str, str] = {}
+ for name in ("Ada", "Priya", "Hidden Person"):
+ cursor.execute(
+ """
+ insert into cataloged_person (person_name, person_side_code)
+ values (%s, 'our_side') returning person_id
+ """,
+ (name,),
+ )
+ people[name] = str(cursor.fetchone()[0])
+ for post_key, actor_name in (
+ ("award", "Ada"),
+ ("spec", "Ada"),
+ ("delivery", "Priya"),
+ ("hidden", "Hidden Person"),
+ ):
+ cursor.execute(
+ """
+ insert into post_summary_result (post_id, korean_summary)
+ values (%s, 'Synthetic summary')
+ """,
+ (post_ids[post_key],),
+ )
+ cursor.execute(
+ """
+ insert into post_summary_role
+ (post_id, actor_name, responsibility, actor_type_code,
+ affiliated_organization_name, cataloged_person_id)
+ values (%s, %s, 'Own the event', 'prov_person', 'Own Corp', %s)
+ """,
+ (post_ids[post_key], actor_name, people[actor_name]),
+ )
+
+ for parent, child, score in (
+ ("award", "spec", 0.91),
+ ("spec", "delivery", 0.82),
+ ("delivery", "voc", 0.73),
+ ("hidden", "voc", 1.00),
+ ):
+ cursor.execute(
+ """
+ insert into post_lineage_edge
+ (parent_post_id, child_post_id, fused_score)
+ values (%s, %s, %s)
+ """,
+ (post_ids[parent], post_ids[child], score),
+ )
+ connection.commit()
+ finally:
+ connection.close()
+
+ try:
+ yield database_dsn, str(own_corporate_entity_id)
+ finally:
+ with admin.cursor() as cursor:
+ cursor.execute(
+ "select pg_terminate_backend(pid) from pg_stat_activity where datname = %s",
+ (database_name,),
+ )
+ cursor.execute(sql.SQL("drop database {}").format(sql.Identifier(database_name)))
+ admin.close()
+
+
+def test_hidden_draft_deleted_and_future_evidence_cannot_change_history(
+ project_history_database: tuple[str, str],
+) -> None:
+ """Exercise production SQL and prove authorization precedes composition."""
+
+ database_dsn, own_corporate_entity_id = project_history_database
+
+ async def run() -> tuple[dict[str, object], str]:
+ connection = await asyncpg.connect(database_dsn)
+ try:
+ focus_post_id = str(
+ await connection.fetchval(
+ "select post_id from source_post where post_title = 'VOC received'"
+ )
+ )
+ hidden_post_id = str(
+ await connection.fetchval(
+ "select post_id from source_post where post_title = 'Hidden handoff'"
+ )
+ )
+ projection = await fetch_project_history_projection(
+ connection,
+ project_key="P-100",
+ focus_post_id=focus_post_id,
+ knowledge_cutoff=datetime.fromisoformat("2026-01-31T23:59:59+00:00"),
+ corporate_entity_ids=[own_corporate_entity_id],
+ limit=16,
+ )
+ return projection, hidden_post_id
+ finally:
+ await connection.close()
+
+ projection, hidden_post_id = asyncio.run(run())
+ titles = [event["event_title"] for event in projection["events"]]
+ assert titles == [
+ "Contract awarded",
+ "Specification revision requested",
+ "Delivery confirmed",
+ "VOC received",
+ ]
+ assert projection["distinct_observed_actor_count"] == 2
+ assert [event["responsibility_transition_code"] for event in projection["events"]] == [
+ None,
+ "continuous",
+ "handoff",
+ "assignment_gap",
+ ]
+ assert all("Hidden" not in title for title in titles)
+ assert all(
+ hidden_post_id not in path["event_ids"]
+ for event in projection["events"]
+ for path in event["related_prior_paths"]
+ )
+ assert len(projection["events"][0]["project_matches"]) == 2
diff --git a/tests/test_project_history_projection.py b/tests/test_project_history_projection.py
new file mode 100644
index 000000000..014b65be1
--- /dev/null
+++ b/tests/test_project_history_projection.py
@@ -0,0 +1,176 @@
+"""Project history projections preserve authority, chronology, and gaps."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+import pytest
+
+from lineageweave.project_history import (
+ build_project_history_projection,
+ classify_project_event,
+ normalize_project_key,
+ responsibility_transition_code,
+)
+
+
+def event(post_id: str, title: str, day: int, **extra: object) -> dict[str, object]:
+ """Return one already-authorized source row."""
+
+ return {
+ "post_id": post_id,
+ "post_title": title,
+ "created_at": datetime(2026, 1, day, 9, tzinfo=timezone.utc),
+ "voc_type_code": "vom",
+ "source_stage_code": None,
+ "source_detail_state_code": None,
+ **extra,
+ }
+
+
+def match(post_id: str, kind: str = "source_project_code", value: str = "P-100") -> dict[str, object]:
+ """Return one matching explicit or semantic project fact."""
+
+ return {
+ "post_id": post_id,
+ "match_kind_code": kind,
+ "matched_value": value,
+ "confidence": None if kind.startswith("source_") else 0.91,
+ "ontology_iri": None if kind.startswith("source_") else "https://w3id.org/lineageweave#Project",
+ "provenance": kind,
+ }
+
+
+def role(post_id: str, name: str, person_id: str | None) -> dict[str, object]:
+ """Return one observed R&R row."""
+
+ return {
+ "post_id": post_id,
+ "actor_name": name,
+ "responsibility": "Own the event",
+ "actor_type_code": "prov_person",
+ "affiliated_organization_name": "Demo Corp",
+ "cataloged_person_id": person_id,
+ "cataloged_team_id": None,
+ "cataloged_corporate_entity_id": None,
+ }
+
+
+def test_normalization_and_display_classification_are_deterministic() -> None:
+ assert normalize_project_key(" P-100 ") == "p-100"
+ assert classify_project_event(
+ title="Specification revision requested",
+ source_stage_code=None,
+ source_detail_state_code=None,
+ voc_type_code="vom",
+ is_focus=False,
+ ) == "specification_changed"
+ assert classify_project_event(
+ title="Account note",
+ source_stage_code=None,
+ source_detail_state_code=None,
+ voc_type_code="voc",
+ is_focus=False,
+ ) == "source_recorded"
+ assert classify_project_event(
+ title="Account note",
+ source_stage_code=None,
+ source_detail_state_code=None,
+ voc_type_code="voc",
+ is_focus=True,
+ ) == "voc_received"
+ with pytest.raises(ValueError, match="empty"):
+ normalize_project_key(" ")
+
+
+def test_responsibility_transition_does_not_invent_assignment_facts() -> None:
+ assert responsibility_transition_code(["person:a"], ["person:a"]) == "continuous"
+ assert responsibility_transition_code(["person:a"], ["person:b"]) == "handoff"
+ assert responsibility_transition_code([], ["person:b"]) == "assignment_gap"
+ assert responsibility_transition_code(["person:a"], []) == "assignment_gap"
+
+
+def test_projection_deduplicates_matches_and_explains_visible_prior_paths() -> None:
+ events = [
+ event("voc", "VOC received", 4, voc_type_code="voc"),
+ event("award", "Contract awarded", 1),
+ event("spec", "Specification revision requested", 2),
+ event("delivery", "Delivery confirmed", 3),
+ event("spec", "Duplicate transport row", 2),
+ ]
+ matches = [
+ match("award"),
+ match("award", "semantic_project_key"),
+ match("spec"),
+ match("delivery", "semantic_project_name", "P-100"),
+ match("voc"),
+ match("voc"),
+ ]
+ roles = [
+ role("award", "Ada", "person-a"),
+ role("spec", "Ada", "person-a"),
+ role("delivery", "Priya", "person-b"),
+ ]
+ edges = [
+ {"parent_post_id": "award", "child_post_id": "spec", "fused_score": 0.91},
+ {"parent_post_id": "spec", "child_post_id": "delivery", "fused_score": 0.82},
+ {"parent_post_id": "delivery", "child_post_id": "voc", "fused_score": 0.73},
+ {"parent_post_id": "voc", "child_post_id": "award", "fused_score": 0.99},
+ {"parent_post_id": "hidden", "child_post_id": "voc", "fused_score": 1.0},
+ ]
+
+ projection = build_project_history_projection(
+ project_key="P-100",
+ focus_event_id="voc",
+ event_rows=events,
+ match_rows=matches,
+ role_rows=roles,
+ edge_rows=edges,
+ )
+
+ assert [item["event_id"] for item in projection["events"]] == [
+ "award",
+ "spec",
+ "delivery",
+ "voc",
+ ]
+ assert projection["event_count"] == 4
+ assert projection["distinct_observed_actor_count"] == 2
+ assert [item["responsibility_transition_code"] for item in projection["events"]] == [
+ None,
+ "continuous",
+ "handoff",
+ "assignment_gap",
+ ]
+ assert len(projection["events"][0]["project_matches"]) == 2
+ assert len(projection["events"][3]["project_matches"]) == 1
+
+ voc_paths = projection["events"][3]["related_prior_paths"]
+ assert [path["source_event_id"] for path in voc_paths] == ["delivery", "spec", "award"]
+ assert voc_paths[-1]["event_ids"] == ["award", "spec", "delivery", "voc"]
+ assert voc_paths[-1]["minimum_fused_score"] == pytest.approx(0.73)
+ assert all(path["truth_status_code"] == "inferred" for path in voc_paths)
+ assert all("hidden" not in path["event_ids"] for path in voc_paths)
+
+
+def test_projection_rejects_invisible_focus_and_out_of_bound_options() -> None:
+ rows = [event("award", "Contract awarded", 1)]
+ with pytest.raises(ValueError, match="focus"):
+ build_project_history_projection(
+ project_key="P-100",
+ focus_event_id="hidden",
+ event_rows=rows,
+ match_rows=[match("award")],
+ role_rows=[],
+ edge_rows=[],
+ )
+ with pytest.raises(ValueError, match="maximum_depth"):
+ build_project_history_projection(
+ project_key="P-100",
+ focus_event_id="award",
+ event_rows=rows,
+ match_rows=[match("award")],
+ role_rows=[],
+ edge_rows=[],
+ maximum_depth=0,
+ )
diff --git a/tests/test_project_history_repository.py b/tests/test_project_history_repository.py
new file mode 100644
index 000000000..44ab85da9
--- /dev/null
+++ b/tests/test_project_history_repository.py
@@ -0,0 +1,181 @@
+"""The project-history repository applies authorization before composition."""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime, timezone
+from typing import Any
+
+import pytest
+
+from backend.app.project_history import (
+ PROJECT_HISTORY_MAXIMUM_LIMIT,
+ ProjectHistoryNotFound,
+ fetch_project_history_projection,
+)
+
+
+class FakeConnection:
+ """Return deterministic rows while recording every SQL invocation."""
+
+ def __init__(self, responses: list[list[dict[str, Any]]]) -> None:
+ self.responses = responses
+ self.calls: list[tuple[str, tuple[object, ...]]] = []
+
+ async def fetch(self, query: str, *args: object) -> list[dict[str, Any]]:
+ """Return the next prepared query result."""
+
+ self.calls.append((query, args))
+ return self.responses.pop(0)
+
+
+def project_match(post_id: str) -> dict[str, object]:
+ """Return one exact explicit project match row."""
+
+ return {
+ "post_id": post_id,
+ "match_kind_code": "source_project_code",
+ "matched_value": "P-100",
+ "confidence": None,
+ "ontology_iri": None,
+ "provenance": "source_post.source_project_code",
+ }
+
+
+def source(post_id: str, day: int) -> dict[str, Any]:
+ """Return one visible project event row."""
+
+ return {
+ "post_id": post_id,
+ "post_title": "Contract awarded" if day == 1 else "VOC received",
+ "created_at": datetime(2026, 1, day, 9, tzinfo=timezone.utc),
+ "voc_type_code": "vom",
+ "source_stage_code": None,
+ "source_detail_state_code": None,
+ }
+
+
+def test_repository_bounds_abac_first_and_constrains_every_child_read() -> None:
+ """Child queries receive only the IDs admitted by the primary ABAC read."""
+
+ connection = FakeConnection(
+ [
+ [source("award", 1), source("voc", 2)],
+ [
+ {
+ "post_id": "award",
+ "match_kind_code": "source_project_code",
+ "matched_value": "P-100",
+ "confidence": None,
+ "ontology_iri": None,
+ "provenance": "source_post.source_project_code",
+ },
+ {
+ "post_id": "voc",
+ "match_kind_code": "semantic_project_key",
+ "matched_value": "P-100",
+ "confidence": 0.9,
+ "ontology_iri": "https://w3id.org/lineageweave#Project",
+ "provenance": "post_project_mention.project_key",
+ },
+ ],
+ [],
+ [{"parent_post_id": "award", "child_post_id": "voc", "fused_score": 0.8}],
+ ]
+ )
+ cutoff = datetime(2026, 1, 3, tzinfo=timezone.utc)
+
+ result = asyncio.run(
+ fetch_project_history_projection(
+ connection, # type: ignore[arg-type]
+ project_key="P-100",
+ focus_post_id="voc",
+ knowledge_cutoff=cutoff,
+ corporate_entity_ids=["corp-1"],
+ limit=8,
+ )
+ )
+
+ assert result["event_count"] == 2
+ event_query, event_args = connection.calls[0]
+ assert "visibility_code = 'public'" in event_query
+ assert "corporate_entity_id::text = any($2::text[])" in event_query
+ assert "source_draft_code" in event_query
+ assert "source_deleted_flag" in event_query
+ assert "post.created_at <= $3" in event_query
+ assert "post_project_mention" in event_query
+ assert event_args == ("p-100", ["corp-1"], cutoff, 9)
+ for _query, args in connection.calls[1:]:
+ assert args[0] == ["award", "voc"]
+
+
+def test_repository_reports_truncation_and_rejects_hidden_focus() -> None:
+ """A focus outside the authorized ID set fails without revealing why."""
+
+ connection = FakeConnection([[source("award", 1), source("voc", 2)], []])
+ with pytest.raises(ProjectHistoryNotFound):
+ asyncio.run(
+ fetch_project_history_projection(
+ connection, # type: ignore[arg-type]
+ project_key="P-100",
+ focus_post_id="hidden",
+ knowledge_cutoff=datetime(2026, 1, 3, tzinfo=timezone.utc),
+ corporate_entity_ids=[],
+ limit=1,
+ )
+ )
+
+
+def test_repository_rejects_unbounded_limits_before_sql() -> None:
+ """Invalid limits fail before any database read."""
+
+ connection = FakeConnection([])
+ with pytest.raises(ValueError, match="limit"):
+ asyncio.run(
+ fetch_project_history_projection(
+ connection, # type: ignore[arg-type]
+ project_key="P-100",
+ focus_post_id=None,
+ knowledge_cutoff=datetime.now(timezone.utc),
+ corporate_entity_ids=[],
+ limit=PROJECT_HISTORY_MAXIMUM_LIMIT + 1,
+ )
+ )
+ assert connection.calls == []
+
+
+class FocusAwareConnection:
+ """Route fake responses by query purpose instead of call order."""
+
+ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
+ """Return focus, timeline, or child evidence for the requested SQL."""
+
+ if "post.post_id = $4" in query:
+ return [source("focus", 10)]
+ if "limit $4" in query:
+ return [source("award", 1), source("middle", 2), source("overflow", 3)]
+ if "match_kind_code" in query:
+ return [project_match("award"), project_match("focus")]
+ if "from post_summary_role" in query:
+ return []
+ if "from post_lineage_edge" in query:
+ return []
+ raise AssertionError(f"unexpected project-history query: {query}")
+
+
+def test_repository_keeps_an_authorized_focus_when_history_is_truncated() -> None:
+ """The current Buyer event stays visible even beyond the earliest page."""
+
+ projection = asyncio.run(
+ fetch_project_history_projection(
+ FocusAwareConnection(), # type: ignore[arg-type]
+ project_key="P-100",
+ focus_post_id="focus",
+ knowledge_cutoff=datetime(2026, 1, 31, tzinfo=timezone.utc),
+ corporate_entity_ids=["corp-1"],
+ limit=2,
+ )
+ )
+
+ assert projection["truncated"] is True
+ assert [event["event_id"] for event in projection["events"]] == ["award", "focus"]
diff --git a/tests/test_tepp_project_history.py b/tests/test_tepp_project_history.py
new file mode 100644
index 000000000..69abdb81a
--- /dev/null
+++ b/tests/test_tepp_project_history.py
@@ -0,0 +1,153 @@
+"""TEPP project histories are typed, cutoff-safe, and source-grounded."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+import pytest
+
+from backend.app.tepp_project_history import build_project_history_request, classify_event_type
+from lineageweave.tepp_project_history import (
+ PROJECT_HISTORY_CONTRACT_VERSION,
+ ProjectHistoryProjection,
+ TeppProjectHistoryClient,
+ TeppProjectHistoryNotAvailable,
+)
+
+
+def source_row(
+ post_id: str,
+ title: str,
+ created_at: str,
+ *,
+ focus: bool = False,
+ voc_type_code: str = "vom",
+ actors: tuple[str, ...] = (),
+) -> dict:
+ """Return one authorized row shape consumed by the request builder."""
+ return {
+ "post_id": post_id,
+ "post_title": title,
+ "created_at": datetime.fromisoformat(created_at.replace("Z", "+00:00")),
+ "source_stage_code": None,
+ "source_detail_state_code": None,
+ "voc_type_code": voc_type_code,
+ "source_project_code": "P-100",
+ "source_project_name": "Northridge renewal",
+ "secondary_grouping_key": "proj-alpha",
+ "evidence_text": f"Evidence: {title}",
+ "actor_ids": list(actors),
+ "is_focus": focus,
+ }
+
+
+def test_request_builder_emits_the_minimum_buyer_cycle_without_raw_body() -> None:
+ rows = [
+ source_row("award", "Contract awarded", "2022-03-11T09:00:00Z", actors=("a",)),
+ source_row(
+ "spec",
+ "Specification revision requested",
+ "2023-06-15T09:00:00Z",
+ actors=("a", "b"),
+ ),
+ source_row("delivery", "Delivery confirmed", "2024-02-20T09:00:00Z", actors=("b",)),
+ source_row("handoff", "Operational handoff recorded", "2024-03-01T09:00:00Z", actors=("b", "c")),
+ source_row(
+ "voc",
+ "Transformer VOC received",
+ "2026-07-30T09:00:00Z",
+ focus=True,
+ voc_type_code="voc",
+ actors=("c",),
+ ),
+ source_row("rebid", "Rebid started", "2026-08-10T09:00:00Z", actors=("c",)),
+ ]
+
+ request = build_project_history_request(
+ rows,
+ focus_post_id="voc",
+ tenant_workspace_id="tenant-demo",
+ knowledge_cutoff=datetime(2026, 8, 19, 23, 59, 59, tzinfo=timezone.utc),
+ )
+
+ assert request.contract_version == PROJECT_HISTORY_CONTRACT_VERSION
+ assert request.project_key == "P-100"
+ assert request.focus_event_id == "voc"
+ assert [event.event_type_code for event in request.events] == [
+ "contract_awarded",
+ "specification_changed",
+ "delivered",
+ "handoff_recorded",
+ "voc_received",
+ "rebid_started",
+ ]
+ assert all(event.availability_basis_code == "source_created_at_proxy" for event in request.events)
+ assert all("post_body" not in event.to_json() for event in request.events)
+
+
+def test_classifier_requires_explicit_event_language_and_focus_for_generic_voc() -> None:
+ assert classify_event_type("Specification revision requested", None, None, "vom", False) == "specification_changed"
+ assert classify_event_type("Operational handoff recorded", None, None, "vom", False) == "handoff_recorded"
+ assert classify_event_type("General account note", None, None, "voc", False) == "source_recorded"
+ assert classify_event_type("General account note", None, None, "voc", True) == "voc_received"
+
+
+def test_client_validates_the_tepp_projection_and_publishes_no_credentials() -> None:
+ captured: dict = {}
+
+ def transport(payload: dict, headers: dict[str, str]) -> dict:
+ captured["payload"] = payload
+ captured["headers"] = headers
+ return {
+ "contract_version": 1,
+ "project_key": "P-100",
+ "project_name": "Northridge renewal",
+ "focus_event_id": "voc",
+ "history_span_start": "2022-03-11T09:00:00Z",
+ "history_span_end": "2026-08-10T09:00:00Z",
+ "participant_count": 3,
+ "inference_status": "temporal_association_only",
+ "events": [
+ {
+ "event_id": "voc",
+ "event_type_code": "voc_received",
+ "event_title": "Transformer VOC received",
+ "occurred_at": "2026-07-30T09:00:00Z",
+ "available_at": "2026-07-30T09:00:00Z",
+ "availability_basis_code": "source_created_at_proxy",
+ "source_post_id": "voc",
+ "evidence_text": "Evidence: Transformer VOC received",
+ "actor_ids": ["c"],
+ }
+ ],
+ "findings": [],
+ }
+
+ request = build_project_history_request(
+ [source_row("voc", "Transformer VOC received", "2026-07-30T09:00:00Z", focus=True, voc_type_code="voc")],
+ focus_post_id="voc",
+ tenant_workspace_id="tenant-demo",
+ knowledge_cutoff=datetime(2026, 8, 19, 23, 59, 59, tzinfo=timezone.utc),
+ )
+ projection = TeppProjectHistoryClient(transport=transport).project(request)
+
+ assert isinstance(projection, ProjectHistoryProjection)
+ assert projection.participant_count == 3
+ assert captured["headers"]["tepp-consumer"] == "lineageweave"
+ assert captured["headers"]["tepp-contract-version"] == "1"
+ assert "authorization" not in {key.lower() for key in captured["headers"]}
+
+
+def test_default_client_and_unpublished_response_fail_closed() -> None:
+ request = build_project_history_request(
+ [source_row("voc", "Transformer VOC received", "2026-07-30T09:00:00Z", focus=True, voc_type_code="voc")],
+ focus_post_id="voc",
+ tenant_workspace_id="tenant-demo",
+ knowledge_cutoff=datetime(2026, 8, 19, 23, 59, 59, tzinfo=timezone.utc),
+ )
+ with pytest.raises(TeppProjectHistoryNotAvailable):
+ TeppProjectHistoryClient().project(request)
+
+ client = TeppProjectHistoryClient(transport=lambda _payload, _headers: {"causal_score": 0.99})
+ with pytest.raises(ValueError, match="project-history projection"):
+ client.project(request)