-
Notifications
You must be signed in to change notification settings - Fork 1
feat(projects): expose evidence-bound history in post detail #668
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
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 dc74d36
test(ui): define accessible project-history timeline contract
seonghobae 09ab37b
test(projects): classify every visible VOC record
seonghobae 3ee2dc7
feat(projects): port evidence-bound history core for RED repair
seonghobae 103b99a
fix(projects): keep lifecycle projection evidence-bound
seonghobae 29d088a
fix(ui): keep project-history selection and tab semantics current
seonghobae 967773a
fix(ui): make project-history evidence and time semantics explicit
seonghobae 5ef8db8
feat(projects): expose evidence-bound history in post detail
97c5d39
docs(gaps): record project-history delivery evidence
e9318ca
fix(frontend): normalize project history actions
07b7656
test(frontend): type project history evidence fixture
facbae5
fix(frontend): ignore blank project code fallback
f272f4b
fix(project-history): guard stale requests and normalize keys
b946051
docs: assign unique project history ADR number
1b8e1a9
fix(project-history): show source state codes
153add7
fix(ui): use theme tokens for project history timeline
e452bf3
fix(projects): make projection checks explicit
1a2fae2
docs(adr): assign unique project-history decision id
4664246
fix(projects): use event time and remove internal UI codes
7a7a7a6
fix(projects): announce history loading state
8b4ed41
fix(projects): suppress transitions across gaps
dd7bd3f
fix(i18n): localize project history loading guidance
d74139d
Merge remote-tracking branch 'origin/main' into restack/pr-668
seonghobae afb7a19
docs(gaps): reconcile exact snapshot inventory
f9c4bd6
fix(project-history): retain explicit source identity
1194f44
docs(adr): reserve project history identity 0243
234f975
fix(ui): keep project history storage fields internal
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ) | ||
| ) | ||
| ) | ||
| """ | ||
|
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} | ||
|
seonghobae marked this conversation as resolved.
|
||
| order by coalesce(post.event_occurred_at, post.created_at), post.created_at, post.post_id | ||
|
seonghobae marked this conversation as resolved.
|
||
| limit $5 | ||
| """ | ||
|
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] | ||
|
seonghobae marked this conversation as resolved.
seonghobae marked this conversation as resolved.
seonghobae marked this conversation as resolved.
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
57
docs/adr/0243-evidence-bound-project-history-projection.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.