Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4ce0a4a
test(projects): define evidence-bound project history contract
seonghobae Aug 20, 2026
dc74d36
test(ui): define accessible project-history timeline contract
seonghobae Aug 20, 2026
09ab37b
test(projects): classify every visible VOC record
seonghobae Aug 20, 2026
3ee2dc7
feat(projects): port evidence-bound history core for RED repair
seonghobae Aug 20, 2026
103b99a
fix(projects): keep lifecycle projection evidence-bound
seonghobae Aug 20, 2026
29d088a
fix(ui): keep project-history selection and tab semantics current
seonghobae Aug 20, 2026
967773a
fix(ui): make project-history evidence and time semantics explicit
seonghobae Aug 20, 2026
5ef8db8
feat(projects): expose evidence-bound history in post detail
Aug 25, 2026
97c5d39
docs(gaps): record project-history delivery evidence
Aug 25, 2026
e9318ca
fix(frontend): normalize project history actions
Aug 25, 2026
07b7656
test(frontend): type project history evidence fixture
Aug 25, 2026
facbae5
fix(frontend): ignore blank project code fallback
Aug 25, 2026
f272f4b
fix(project-history): guard stale requests and normalize keys
Aug 25, 2026
b946051
docs: assign unique project history ADR number
Aug 25, 2026
1b8e1a9
fix(project-history): show source state codes
Aug 26, 2026
153add7
fix(ui): use theme tokens for project history timeline
Aug 26, 2026
e452bf3
fix(projects): make projection checks explicit
Aug 26, 2026
1a2fae2
docs(adr): assign unique project-history decision id
Aug 26, 2026
4664246
fix(projects): use event time and remove internal UI codes
Aug 26, 2026
7a7a7a6
fix(projects): announce history loading state
Aug 26, 2026
8b4ed41
fix(projects): suppress transitions across gaps
Aug 26, 2026
dd7bd3f
fix(i18n): localize project history loading guidance
Aug 26, 2026
d74139d
Merge remote-tracking branch 'origin/main' into restack/pr-668
seonghobae Aug 26, 2026
afb7a19
docs(gaps): reconcile exact snapshot inventory
Aug 26, 2026
f9c4bd6
fix(project-history): retain explicit source identity
Aug 26, 2026
1194f44
docs(adr): reserve project history identity 0243
Aug 26, 2026
234f975
fix(ui): keep project history storage fields internal
Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project are documented here. Format follows

### Added

- An evidence-bound project-history projection and post-detail timeline
(ADR 0243 / #280) reuse normalized project, responsibility, and Event
Lineage rows under RBAC/ABAC and cutoff filtering. Free text never creates a
lifecycle fact; unsupported authoritative ingestion remains issue #284.
- Persist explicit paragraph, list, table, MathML formula, and caller-parsed
conversation-turn semantic-unit kinds without inferring absent boundaries.
- Event Lineage now persists each reconstructed connection's independent
Expand Down
32 changes: 32 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@
fetch_post_evaluation,
ingest_post_evaluation,
)
from backend.app.project_history import (
ProjectHistoryNotFound,
fetch_project_history_projection,
)
from backend.app.post_summary_ingestion import (
fetch_persisted_summary,
persist_post_summary,
Expand Down Expand Up @@ -2275,6 +2279,34 @@ async def read_ontology_neighborhood(
return payload


@app.get("/api/projects/{project_key}/history")
async def read_project_history(
project_key: str,
focus_post_id: UUID | None = Query(None),
knowledge_cutoff: str | None = Query(None),
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""Return one authorization-bounded project-history evidence projection."""

_require_post_read(account)
try:
cutoff = parse_as_of_clock(knowledge_cutoff) if knowledge_cutoff else datetime.now(timezone.utc)
async with pool.acquire() as conn:
return await fetch_project_history_projection(
conn,
project_key=project_key,
focus_post_id=str(focus_post_id) if focus_post_id else None,
knowledge_cutoff=cutoff,
corporate_entity_ids=sorted(account.corporate_entity_ids),
process_unit_ids=sorted(account.process_unit_ids),
)
except ValueError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
except ProjectHistoryNotFound:
raise HTTPException(status.HTTP_404_NOT_FOUND, "project history not found") from None
Comment thread
seonghobae marked this conversation as resolved.


@app.get("/api/posts/{post_id}/counterparties")
async def read_post_counterparties(
post_id: str,
Expand Down
238 changes: 238 additions & 0 deletions backend/app/project_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
"""ABAC-safe PostgreSQL projection for customer-facing 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."""

pass


_ELIGIBILITY = SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")
_PROJECT_MATCH = """
(
lower(btrim(normalize(coalesce(post.source_project_code, ''), NFKC))) = $1
or lower(btrim(normalize(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(btrim(normalize(mention.project_key, NFKC))) = $1
or lower(btrim(normalize(mention.project_name, NFKC))) = $1
)
)
)
"""
Comment thread
seonghobae marked this conversation as resolved.
_EVENT_SQL = f"""
select post.post_id,
post.post_title,
post.created_at,
post.event_occurred_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 (cardinality($3::text[]) = 0
or post.process_unit_id::text = any($3::text[]))))
and {_ELIGIBILITY}
and post.created_at <= $4
and {_PROJECT_MATCH}
Comment thread
seonghobae marked this conversation as resolved.
order by coalesce(post.event_occurred_at, post.created_at), post.created_at, post.post_id
Comment thread
seonghobae marked this conversation as resolved.
limit $5
"""
Comment thread
seonghobae marked this conversation as resolved.
_FOCUS_SQL = f"""
select post.post_id,
post.post_title,
post.created_at,
post.event_occurred_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 (cardinality($3::text[]) = 0
or post.process_unit_id::text = any($3::text[]))))
and {_ELIGIBILITY}
and post.created_at <= $4
and post.post_id = $5::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(btrim(normalize(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(btrim(normalize(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(btrim(normalize(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(btrim(normalize(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],
process_unit_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),
list(process_unit_ids),
knowledge_cutoff,
limit + 1,
)
)
truncated = len(rows) > limit
event_rows = rows[:limit]
transition_suppressed_event_ids: set[str] = set()
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),
list(process_unit_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]]
transition_suppressed_event_ids.add(str(focus_rows[0]["post_id"]))
event_rows.sort(
key=lambda row: (
row.get("event_occurred_at") or row["created_at"],
row["created_at"],
str(row["post_id"]),
)
)
visible_ids = [str(row["post_id"]) for row in event_rows]
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.

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,
transition_suppressed_event_ids=transition_suppressed_event_ids,
)


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
57 changes: 57 additions & 0 deletions docs/adr/0243-evidence-bound-project-history-projection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# ADR 0243: Evidence-bound project history projection

- Status: Accepted
- Date: 2026-08-26
- Issues: #280, #284
- Figma file ID: `SBpgot7uTvMxEaxUwvoc0S`

## Context

The PRD requires an operations analyst to find a project and inspect cited
evidence. Project evidence already exists in normalized `source_post`,
`post_project_mention`, `post_summary_role`, and `post_lineage_edge` rows. A
second project-history ledger would duplicate truth. Free-text lifecycle
classification would also turn words into unsupported business facts.

## Decision

`GET /api/projects/{project_key}/history` returns a read-only projection over
those existing rows. RBAC, corporate-entity scope, process-unit scope, source
eligibility, and knowledge cutoff are applied before child evidence is read.
Project identity uses exact NFKC-normalized source or semantic evidence; no
fuzzy match is allowed.

The existing post-detail popup hosts the shared timeline; there is no new
navigation destination. Controlled VOC codes may label VOC evidence. Other
records remain `source_recorded`; source stage and detail-state codes are shown
without inferred lifecycle meaning. Adjacent responsibility rows describe
document evidence only. Persisted Event Lineage paths are labelled related and
non-causal. Dates use `source_post.event_occurred_at` when recorded and disclose
`source_post.created_at` as the fallback clock.

Responsibility change is shown only when two displayed records are adjacent in
the authorized source ordering. If truncation retains a focus record but omits
intermediate records, that focus record has no responsibility-transition code;
the projection must not imply a direct handover or continuity across the gap.

The projection is bounded and declares truncation. A missing or unauthorized
project is indistinguishable as HTTP 404. The Figma identifier records the
design authority; Storybook remains the executable state inventory.

## Consequences

- Users can move from one permitted post to project-wide evidence without a
duplicate store or invented handover interval.
- Issue #280 is satisfied only after protected-main API, UI, Storybook, and
screenshot evidence exists.
- Issue #284 remains open until an owned source adapter supplies authoritative,
versioned lifecycle events and idempotent reconciliation. This projection
must not impersonate that future write boundary.

## References

W3C. (2013). *PROV-O: The PROV ontology*. World Wide Web Consortium.
https://www.w3.org/TR/2013/REC-prov-o-20130430/

W3C. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2*. World Wide Web
Consortium. https://www.w3.org/TR/WCAG22/
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ decision from them.
| [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) |
| [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) |
| [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md), [0222](0222-project-nodes-in-ontology-neighborhood.md) |
| Project-history timeline | [0243](0243-evidence-bound-project-history-projection.md) |
| [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0213](0213-global-ask-embedding-pool-release.md) |
| [`GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md`](../doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md) | [0215](0215-global-ask-public-claim-verification.md) |
| [`GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md`](../doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md) | [0216](0216-global-ask-knowledge-cutoff.md) |
Expand Down
Loading
Loading