Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
21b4d4a
feat(projects): promote evidence-bound project history onto main
Aug 27, 2026
2b5da49
docs(gap-baseline): record #643, #644, #762 deliveries
Aug 27, 2026
e6ca33d
codex: address PR review feedback (#762)
Aug 27, 2026
d4a8feb
docs(gaps): refresh exact-head loop overlay
Aug 27, 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
32 changes: 32 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@
fetch_occupation_ratings,
fetch_rating_source_occupations,
)
from backend.app.project_history import (
ProjectHistoryNotFound,
ProjectHistoryRequestError,
fetch_project_history_projection,
)
from backend.app.post_summary_ingestion import (
fetch_persisted_summary,
persist_post_summary,
Expand Down Expand Up @@ -2588,6 +2593,33 @@ async def search_occupational_constructs(
) from None
return search_page_to_payload(page)

@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 ProjectHistoryRequestError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
Comment on lines +2607 to +2619

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Malformed cutoff parameter returns 500 instead of 422

A malformed knowledge_cutoff makes parse_as_of_clock raise a plain ValueError, which the handler does not catch (only ProjectHistoryRequestError and ProjectHistoryNotFound are), so the request fails with HTTP 500 instead of 422. The sibling endpoint search_occupational_constructs catches this ValueError and returns 422.

Suggested change
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 ProjectHistoryRequestError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
_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 ProjectHistoryRequestError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

except ProjectHistoryNotFound:
raise HTTPException(status.HTTP_404_NOT_FOUND, "project history not found") from None


@app.get("/api/posts/{post_id}/counterparties")
async def read_post_counterparties(
Expand Down
246 changes: 246 additions & 0 deletions backend/app/project_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
"""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")
_ASCII_EDGE_WHITESPACE = r"E' \t\n\r\f\v'"
_PROJECT_MATCH = """
(
lower(btrim(normalize(coalesce(post.source_project_code, ''), NFKC), {whitespace})) = $1
or lower(btrim(normalize(coalesce(post.source_project_name, ''), NFKC), {whitespace})) = $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), {whitespace})) = $1
or lower(btrim(normalize(mention.project_name, NFKC), {whitespace})) = $1
)
)
)
""".format(whitespace=_ASCII_EDGE_WHITESPACE)
Comment on lines +26 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: SQL/Python key-normalization parity

Project-key matching applies NFKC normalize, ASCII-edge trim, and lowercase identically in SQL and in normalize_project_key, with parity asserted by a test. The frontend normalizeProjectIdentity uses JS trim() (broader Unicode whitespace), but that only affects client-side display dedup, not the backend match.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

_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}
order by coalesce(post.event_occurred_at, post.created_at), post.created_at, post.post_id
limit $5
"""
_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
"""
Comment on lines +61 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Focus re-query stays authorization-bound

The focus re-query in _FOCUS_SQL repeats the same visibility, ABAC, eligibility, cutoff, and project-match conditions as the primary query. An unauthorized or non-matching focus id returns no rows and yields 404, so focus_post_id cannot probe hidden posts.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

_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), {whitespace})) = $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), {whitespace})) = $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), {whitespace})) = $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), {whitespace})) = $2
order by post_id, match_kind_code, matched_value
""".format(whitespace=_ASCII_EDGE_WHITESPACE)
_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."""


class ProjectHistoryRequestError(ValueError):
"""The caller supplied a project-history parameter outside its contract."""


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 ProjectHistoryRequestError("project history limit is outside the supported bound")
try:
normalized_key = normalize_project_key(project_key)
except ValueError as exc:
raise ProjectHistoryRequestError(str(exc)) from exc
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 on lines +193 to +216

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Truncated-focus adjacency suppression holds

When the focus event falls beyond the earliest page, fetch_project_history_projection keeps the earliest limit-1 events plus the focus, marks the focus in transition_suppressed_event_ids, and re-sorts. The focus then carries no responsibility_transition_code, so no handover is implied across the omitted gap. Matches ADR 0243 and is test-covered.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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/
23 changes: 23 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Product & Technical Gap Baseline

> Exact-head loop overlay: 2026-08-28 KST. Protected `main` was
> `bbb191924e9881a5201f1ecf63c854d92992cc1c`; seven PRs and nine issues were
> open. PR #763 was `b51d3bd8872b` and PR #762 was `e6ca33dba1b5`; both were
> mergeable, normal squash auto-merge was enabled, exact-head Checks were still
> running, and no qualifying independent approval existed. PRs #702
> (`93e7b81d096d`), #679 (`135dfe7c4266`), #672 (`a3e87a89185f`), #667
> (`0c0f4af572a9`), and #640 (`bd73e0a43ae1`) remained draft and dirty against
> `main`. Central ruleset 18156473 and repository no-force-push ruleset
> 21065108 remain active. This overlay supersedes every older queue count below.
> Checks from older heads, stacked bases, or merged PRs are not transferred.
>
> Current-runtime boundary: the official Compose project was healthy at the
> HTTP health route, but its PostgreSQL schema did not yet contain
> `source_post_voice`; therefore no current Voice-history aggregate,
> authenticated project-history API result, or rendered authenticated UI result
> is claimed. Older aggregate observations below remain dated supporting
> evidence, not confirmation of this exact head. The checked repository names
> are `ContextualWisdomLab/LineageWeave`, `RankWeave`, `ThreadWeave`, `TEPP`,
> and lowercase canonical `ContextualWisdomLab/disksage`.

> Voice-of-X delivery snapshot: 2026-08-27 KST. Protected `main` was
> `ff7431bd1851c03e737808d22c6a2d43968582f9`; PR #713 was
> `850494c3861703862a76cfe564381a41243c6c2d`; stacked PR #717 was
Expand Down Expand Up @@ -781,6 +801,9 @@ post-merge reruns (not transferable evidence for later heads):
| ---: | --- | --- |
| #644 | Native workspace surface split: 9 conditionally rendered components as lazy() dynamic imports behind a SurfaceBoundary error boundary (fallback spinner, alert + admin-guidance recovery). Build emits 9 chunks (1.5-37 kB) with main bundle 543 kB; 460 frontend tests, tsc, Storybook build green. PublicClaimVerification stays eager for synchronous Ask rendering | ADR 0220-adjacent |
| #643 | Shared token-backed StatusNotice with success/unavailable/retry states (ADR 0220), WorkspaceCalendar auth-unavailable copy, 5-locale i18n; Full suite 22m54s green | ADR 0220 |
| #643 | Shared StatusNotice (ADR 0220): success/unavailable/retry states, WorkspaceCalendar auth-unavailable copy, 5-locale i18n; CI Full suite 22m54s green | ADR 0220 |
| #644 | Native workspace surface split: 9 conditionally rendered components as lazy() dynamic imports behind a SurfaceBoundary error boundary; build emits 9 chunks (1.5-37 kB), main bundle 543 kB; 470 frontend tests, tsc, Storybook green | — |
| #762 | Evidence-bound project history (ADR 0243): /api/projects/{key}/history endpoint, project_history.py projection, fetchProjectHistory client, standalone ProjectHistoryTimeline component; supersedes #668 (3-way merge kept only the additive +2279/-0, dropping the branch's 8k shared-file reverts; popup UI hookup deferred as a scoped follow-up) | ADR 0243 |
| #761 | Temporal imported-primary Voice history (ADR 0252): migration 0243 (`effective_to` + GiST primary-period exclusion + synchronize trigger), refined 0237 `least()` effective_from backfill, `effective_from/effective_to` dataclass/export + `coalesce($2,$3)` cutoff predicate. Completes the half-shipped main layer that queried `voice.effective_to` against a missing column. CI Full suite 19m13s green | ADR 0252 |
| #629 | Provider work released before embedding pool bound; landing reads bounded (k6-verified concurrency); merged with strix-only infra timeout (Full suite + all other gates green) | — |
| #750 | Leftover-map unexplained leftover share persisted (`report_leftover_map_unexplained_share`, share `s = U² / R²`) | ADR 0233 |
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,12 @@ https://storybook.js.org/docs/get-started/frameworks/react-vite

World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines
(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/
# Project history timeline

`Projects/History Timeline` covers the evidence-bearing default
timeline and its exact-value table. Keyboard roving focus, the current event,
responsibility-evidence gaps, non-causal lineage paths, and source-record
actions are executable component-test states governed by ADR 0243. The
1440×1000 and 390×844 audits are retained in
`docs/screenshots/project-history-time-source-{desktop,mobile}.png`; both show
the customer-readable time source without exposing the stored basis code.
Loading
Loading