-
Notifications
You must be signed in to change notification settings - Fork 1
feat(projects): evidence-bound project history (ADR 0243) #762
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
Changes from all commits
4ce0a4a
dc74d36
09ab37b
3ee2dc7
103b99a
29d088a
967773a
5ef8db8
97c5d39
e9318ca
07b7656
facbae5
f272f4b
b946051
1b8e1a9
153add7
e452bf3
1a2fae2
4664246
7a7a7a6
8b4ed41
dd7bd3f
d74139d
afb7a19
f9c4bd6
1194f44
234f975
21b4d4a
2b5da49
e6ca33d
d4a8feb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Focus re-query stays authorization-bound The focus re-query in 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, 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 | ||
| 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/ |
There was a problem hiding this comment.
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_cutoffmakesparse_as_of_clockraise a plainValueError, which the handler does not catch (onlyProjectHistoryRequestErrorandProjectHistoryNotFoundare), so the request fails with HTTP 500 instead of 422. The sibling endpointsearch_occupational_constructscatches thisValueErrorand returns 422.Was this helpful? React with 👍 or 👎 to provide feedback.