diff --git a/AGENTS.md b/AGENTS.md index 47a71c8c3..3a5b17c0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,30 @@ A run-bearing analysis-run registry empties only after an unrevoked (ADR 0020 / v0.87.0). The documented phrase is not a secret. Do not expose purge on a public HTTP route. +`make seed` writes a Demo Corp lineage run and a TEPP run on the same +snapshot (ADR 0013). The TEPP path goes through `tepp_client`. A missing +transport or an unused accepted envelope is Failed +(`tepp_not_available` / `tepp_result_not_persisted`). Do not invent a +theta or a local psychometric substitute. The home list caption stays +`kind · status · entity`; the machine failure code is detail-only +(ADR 0014). Open a Failed TEPP row, then connect a live TEPP +transport. A failed lineage row retries reconstruction -- it does not +mention TEPP. A failed period-report row rebuilds the report. A +pending TEPP row does not claim a calibrated measurement. A pending +lineage row says reconstruction has not started yet. +Digest prefixes stay audible; hover a prefix to read the full digest. +Opening a cutoff title shows the live post -- compare it with the +cutoff before treating the body as reconstructed evidence (ADR 0016). +`POST /api/analysis-runs` records Pending on an authorized +cutoff capture (ADR 0017) and does not reconstruct lineage. +A thread-group run lists only when an ABAC-visible post exists at or +before `knowledge_cutoff`, even when the signed-in account requested +the run (ADR 0018). Requesting a January thread that has no in-cutoff +visible post does not put that row on the home list. A 404 on that +hidden row must stay generic: do not name the thread or the cutoff. +After that 404, re-read the authorized list so the stale row does +not stay clickable. + ## CI gates `.github/workflows/tests.yml` runs the full suite on every PR to `main`. diff --git a/CHANGELOG.d/0.87.1-requester-thread-cutoff.md b/CHANGELOG.d/0.87.1-requester-thread-cutoff.md new file mode 100644 index 000000000..e75a5e3b6 --- /dev/null +++ b/CHANGELOG.d/0.87.1-requester-thread-cutoff.md @@ -0,0 +1,4 @@ +A thread-group run you requested stays off the home list unless that +thread already has an in-cutoff visible post. Request still records a +corporate-scope lineage run. Same-named R&R people bind the earliest +catalog row. diff --git a/CHANGELOG.d/0.87.2-hidden-run-next-action.md b/CHANGELOG.d/0.87.2-hidden-run-next-action.md new file mode 100644 index 000000000..83982a1e8 --- /dev/null +++ b/CHANGELOG.d/0.87.2-hidden-run-next-action.md @@ -0,0 +1,4 @@ +Opening a listed analysis-run that then 404s tells you to open a +visible run from the home list, or request a lineage reconstruction +for a corporation you already walk. The message does not name the +thread or the cutoff. diff --git a/CHANGELOG.d/0.87.4-stale-hidden-run-list.md b/CHANGELOG.d/0.87.4-stale-hidden-run-list.md new file mode 100644 index 000000000..9e13b83ac --- /dev/null +++ b/CHANGELOG.d/0.87.4-stale-hidden-run-list.md @@ -0,0 +1,5 @@ +Opening a listed analysis-run that then 404s drops that stale row +from the home list after an authorized re-read, announces the next +action with a status alert, and leaves Request as the named +reconstruction control. The message still does not name the thread +or the cutoff. diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a19fe92..e360103e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.87.4] - 2026-08-16 + +### Fixed + +- Opening a listed analysis-run that then 404s drops that stale row + from the home list after an authorized re-read. The next action is + announced as a status alert: open a remaining visible run, or + request a lineage reconstruction. The message still does not name + the thread or the cutoff (ADR 0014 / ADR 0018). + +## [0.87.2] - 2026-08-16 + +### Fixed + +- Opening a listed analysis-run that then 404s tells you to open a + visible run from the home list, or request a lineage reconstruction + for a corporation you already walk. The message does not name the + thread or the cutoff, so a hidden row stays unconfirmed (ADR 0014 / + ADR 0018). + +## [0.87.1] - 2026-08-16 + +### Fixed + +- A thread-group analysis-run you requested no longer appears on the + home list when that thread has no ABAC-visible post at or before + `knowledge_cutoff` (ADR 0018). The row appears when that thread + already has an in-cutoff visible post. Request still records a + corporate-scope lineage run (ADR 0017). Detail of the hidden row + is 404. +- An R&R person name that matches two catalog rows now binds the + earliest `created_at`, then `person_id`. A later same-named Keyman + row no longer steals the mention. + ## [0.87.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 870c77f87..3b2678d95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,3 +33,10 @@ Opening a cutoff title shows the live post -- compare it with the cutoff before treating the body as reconstructed evidence (ADR 0016). `POST /api/analysis-runs` records Pending on an authorized cutoff capture (ADR 0017) and does not reconstruct lineage. +A thread-group run lists only when an ABAC-visible post exists at or +before `knowledge_cutoff`, even when the signed-in account requested +the run (ADR 0018). Requesting a January thread that has no in-cutoff +visible post does not put that row on the home list. A 404 on that +hidden row must stay generic: do not name the thread or the cutoff. +After that 404, re-read the authorized list so the stale row does +not stay clickable. diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index d26eb6f6e..25cf7730e 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -1,10 +1,11 @@ """Authorized, source-redacting reads of the Milestone 2 analysis-run registry. The registry itself is issue #89 / migration 0018. This module is the -product projection: an account sees only runs they requested or whose -scope they already have ABAC authority to walk. Aggregate counts and -lookup labels come back; source SQL, DSNs, raw records, and provider -payloads never do. +product projection: an account sees runs they requested or whose scope +they already have ABAC authority to walk, except a thread-group run +also needs an in-cutoff visible post (ADR 0018). Requester ownership +does not bypass that clock. Aggregate counts and lookup labels come +back; source SQL, DSNs, raw records, and provider payloads never do. ``create_pending_analysis_run`` (ADR 0017) writes snapshot, counts, run, scope, and the first Pending event atomically. It does not reconstruct @@ -33,23 +34,8 @@ "analysis_run_tepp": "tepp-run-v1", } -_VISIBLE_RUN_SQL = """ - run.requested_by_account_id = $1 - or ( - scope.scope_kind_code = 'analysis_scope_corporate_entity' - and scope.corporate_entity_id = any($2::uuid[]) - ) - or ( - scope.scope_kind_code = 'analysis_scope_process_unit' - and exists ( - select 1 from account_affiliation aff - where aff.user_account_id = $1 - and aff.process_unit_id = scope.process_unit_id - ) - ) - or ( - scope.scope_kind_code = 'analysis_scope_thread_group' - and exists ( +_THREAD_GROUP_IN_CUTOFF_SQL = """ + exists ( select 1 from source_post p where p.thread_group_key = scope.scope_key and p.created_at <= run.knowledge_cutoff @@ -58,6 +44,31 @@ or p.corporate_entity_id = any($2::uuid[]) ) ) +""" + +_VISIBLE_RUN_SQL = f""" + ( + run.requested_by_account_id = $1 + or ( + scope.scope_kind_code = 'analysis_scope_corporate_entity' + and scope.corporate_entity_id = any($2::uuid[]) + ) + or ( + scope.scope_kind_code = 'analysis_scope_process_unit' + and exists ( + select 1 from account_affiliation aff + where aff.user_account_id = $1 + and aff.process_unit_id = scope.process_unit_id + ) + ) + or ( + scope.scope_kind_code = 'analysis_scope_thread_group' + and {_THREAD_GROUP_IN_CUTOFF_SQL} + ) + ) + and ( + scope.scope_kind_code <> 'analysis_scope_thread_group' + or {_THREAD_GROUP_IN_CUTOFF_SQL} ) """ @@ -203,7 +214,7 @@ async def fetch_visible_analysis_runs( account_id: str, affiliated_entity_ids: list[str], ) -> list[dict[str, Any]]: - """Runs the account requested or whose scope they may already walk.""" + """Runs the account may walk, with thread-group cutoff still applied.""" rows = await conn.fetch( _RUN_SELECT.format(where=_VISIBLE_RUN_SQL), account_id, diff --git a/backend/app/main.py b/backend/app/main.py index adb7a20a8..fb95ccacd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1194,8 +1194,10 @@ async def list_analysis_runs( ) -> dict[str, Any]: """Authorized analysis-run list: aggregates and labels only. - Hidden scopes 404 at the item path and never appear here. The - payload has no source SQL, DSN, raw record, or provider body. + Hidden scopes 404 at the item path and never appear here. A + thread-group run you requested still needs an in-cutoff visible + post (ADR 0018). The payload has no source SQL, DSN, raw record, + or provider body. """ _require_post_read(account) async with pool.acquire() as conn: @@ -1261,7 +1263,9 @@ async def read_analysis_run( ) -> dict[str, Any]: """One authorized analysis-run projection, or 404 when hidden. - Detail adds the labeled status history. Hidden runs never leak events. + Detail adds the labeled status history. Hidden runs never leak + events. A thread-group run you requested still needs an in-cutoff + visible post (ADR 0018). """ _require_post_read(account) try: diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 3febf9b21..085762554 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -257,7 +257,10 @@ async def _replace_summary_projection( ) elif role.actor_type_code == ACTOR_TYPE_PERSON: person_row = await conn.fetchrow( - "select person_id from cataloged_person where person_name = $1 limit 1", + "select person_id from cataloged_person " + "where person_name = $1 " + "order by created_at, person_id " + "limit 1", role.actor_name, ) if person_row is not None: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3b74c22a3..3913f45e6 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1706,6 +1706,86 @@ def test_thread_group_run_list_honors_knowledge_cutoff( assert seeded_db["visible_run_id"] in ids +def test_requester_owned_thread_group_run_list_honors_knowledge_cutoff( + client, demo_analyst_token, seeded_db +) -> None: + """Requesting a January thread-group run does not list it without an in-cutoff post.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code, thread_group_key, created_at) " + "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public', %s, %s " + "from source_post where post_id = %s", + ( + "Late requester thread-group post", + "Written after the January cutoff.", + "late-requester-thread-group", + "2026-01-20T12:00:00Z", + seeded_db["own_private_post_id"], + ), + ) + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'source-contract-v1', + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + ("e" * 64,), + ) + snapshot_id = cur.fetchone()[0] + cur.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, 'analysis_run_lineage', %s, + (select user_account_id from user_account + where email_address = 'test.analyst@example.test'), + '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, + '2026-01-12T12:30:00Z') + returning analysis_run_id + """, + (snapshot_id, "hidden-own-late-thread", "b" * 64, "c" * 40), + ) + run_id = str(cur.fetchone()[0]) + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, scope_key) + values (%s, 'analysis_scope_thread_group', 'late-requester-thread-group') + """, + (run_id,), + ) + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values (%s, 1, 'analysis_status_succeeded', '2026-01-12T12:33:00Z') + """, + (run_id,), + ) + finally: + admin_conn.close() + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + listed = client.get("/api/analysis-runs", headers=headers) + assert listed.status_code == 200 + ids = {run["analysis_run_id"] for run in listed.json()["analysis_runs"]} + assert run_id not in ids + assert seeded_db["visible_run_id"] in ids + + hidden = client.get(f"/api/analysis-runs/{run_id}", headers=headers) + assert hidden.status_code == 404 + + def test_first_mention_of_a_new_counterparty_creates_a_real_corporate_entity( client, demo_analyst_token, seeded_db, monkeypatch ) -> None: diff --git a/docs/adr/0009-cross-post-actor-identity.md b/docs/adr/0009-cross-post-actor-identity.md index 7bdbfa091..d53bda606 100644 --- a/docs/adr/0009-cross-post-actor-identity.md +++ b/docs/adr/0009-cross-post-actor-identity.md @@ -44,6 +44,9 @@ counterparty and Keyman affiliation already resolves against). **Person** (an R&R actor, not a Keyman): opportunistically joined to an *existing* `cataloged_person` row by exact name match, when Keyman extraction has already cataloged that name on this or another post. +When two rows share `person_name`, the join takes the earliest +`created_at`, then `person_id` — it does not invent a person or pick +an arbitrary `LIMIT 1` row. R&R does not create a new person identity itself -- `cataloged_person` requires `person_side_code` (our-side vs. counterparty), which R&R's prompt does not currently ask for and Keyman's does; inventing one here diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 500c2bc2a..e94dae7f3 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -53,7 +53,14 @@ started yet. The detail now shows the legal lifecycle the registry already stored. `POST /api/analysis-runs` now records a Pending run on an authorized cutoff capture (ADR 0017). Reconstruction, a live TEPP transport, and a fuller Analysis Run -Console remain later slices. +Console remain later slices. A 404 on a hidden run (including a +thread-group row the caller requested that still lacks an in-cutoff +visible post, ADR 0018) must stay generic: do not name the thread or +the cutoff. Tell the operator to open a visible run from the home +list, or request a lineage reconstruction for a corporation they +already walk. After that 404, re-read `GET /api/analysis-runs` so +the stale list row does not stay clickable, and announce the status +with `role="alert"` (WCAG 2.2 SC 4.1.3). ## References @@ -62,6 +69,10 @@ Association, & National Council on Measurement in Education. (2014). *Standards for educational and psychological testing*. American Educational Research Association. +Kirkpatrick, A., Campbell, A., Cooper, M., & Adams, C. (Eds.). (2023). +*Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). +World Wide Web Consortium. https://www.w3.org/TR/WCAG22/ + Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/2013/REC-prov-o-20130430/ diff --git a/docs/adr/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md index ae0a1c331..5fcd0d55b 100644 --- a/docs/adr/0018-related-nodes-team-org-walk.md +++ b/docs/adr/0018-related-nodes-team-org-walk.md @@ -40,6 +40,9 @@ the id by `corporate_entity.entity_name`. Thread-group run list visibility requires at least one ABAC-visible `source_post` whose `created_at` is at or before `knowledge_cutoff`. +Requester ownership (`requested_by_account_id`) does not bypass that +clock: a run you requested on a thread that has no in-cutoff visible +post stays off the home list and returns 404 on detail. ## Consequences @@ -49,6 +52,10 @@ Thread-group run list visibility requires at least one ABAC-visible organization chip. - A later public post in a thread group no longer lists a January run that could not have known that post. +- Requesting that January run yourself does not put it on your home + list. The row appears when the thread already has an in-cutoff + visible post. Request still records a corporate-scope lineage run + (ADR 0017). ## References diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index c776053b1..4cfe7a9ee 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -10,6 +10,7 @@ | W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. | | W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Opening a listed title warns that the live body may have changed after that cutoff. | | W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. | +| WCAG 2.2 SC 4.1.3 Status Messages | Announce a hidden-run 404 without confirming why the row is hidden, then drop the stale list row. | `StatusAlert` (`role="alert"`) plus a post-404 `GET /api/analysis-runs` re-read (ADR 0014 / ADR 0018). | | ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | | PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. | | NIST SP 800-92 | Treat audit records as bounded, protected operational evidence rather than unstructured application logging. | Append-only status events, machine failure codes, actor identity, occurrence/record clocks, fail-closed rollback, `invoking_session_role` on each retention event, and exclusion of raw source/provider payloads. | @@ -95,6 +96,10 @@ Kent, K., & Souppaya, M. (2006). *Guide to computer security log management* (NIST Special Publication 800-92). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-92 +Kirkpatrick, A., Campbell, A., Cooper, M., & Adams, C. (Eds.). (2023). +*Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). +World Wide Web Consortium. https://www.w3.org/TR/WCAG22/ + National Institute of Standards and Technology. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 diff --git a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md index 2f0647dca..ff0979b38 100644 --- a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md +++ b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md @@ -8,13 +8,18 @@ the Storybook inventory. | Source | Product implication | Implemented evidence | |---|---|---| -| W3C Design Tokens Format Module 1.0 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--radius-chip`, and `--font-*`. `CitationChip` and `PopupCloseButton` read those names through `App.css`. | +| W3C Design Tokens Format Module 1.0 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--radius-chip`, and `--font-*`. `CitationChip`, `PopupCloseButton`, and `StatusAlert` read those names through `App.css`. | | Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. | +| WCAG 2.2 SC 4.1.3 Status Messages | Announce a fail-closed status without moving focus, so the operator hears the next action. | `StatusAlert` uses `role="alert"` and `--color-status-alert`. | ## APA 7th references Design Tokens Community Group. (2025). *Design Tokens Format Module 1.0* (W3C Community Group Draft Report). https://tr.designtokens.org/format/ +Kirkpatrick, A., Campbell, A., Cooper, M., & Adams, C. (Eds.). (2023). +*Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). +World Wide Web Consortium. https://www.w3.org/TR/WCAG22/ + Storybook. (2026). *Storybook for React & Vite*. https://storybook.js.org/docs/get-started/frameworks/react-vite diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 282e3515e..6c28a520b 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -7,6 +7,7 @@ buyer-facing control you can click before changing product CSS. |---|---|---| | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | +| `Chrome/StatusAlert` | Hear the next action after a hidden-run 404, then open a visible run or request a reconstruction. | `--color-status-alert`, `StatusAlert` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module under `frontend/src/components/`. Do not add a second Node package manager; diff --git a/frontend/package.json b/frontend/package.json index 0d43d9fa2..74c73413a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.87.0", + "version": "0.87.4", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 5251e69f8..529398ea3 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -27,6 +27,10 @@ color: #b91c1c; } +.status-alert { + color: var(--color-status-alert); +} + .post-list { list-style: none; padding: 0; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fd8a15146..ff950de15 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -63,6 +63,7 @@ describe("App, authenticated", () => { failedReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; + hiddenAnalysisRun?: boolean; postBody?: string; }) { const statusLabel: Record = { @@ -86,6 +87,7 @@ describe("App, authenticated", () => { const events: { event_id: string; event_type: string; actor_account_id: string; summary: string }[] = []; let nextEventId = 1; let createdPendingLineage: Record | null = null; + let analysisRunListCalls = 0; const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -283,6 +285,14 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/analysis-runs/run-demo-lineage")) { + if (options?.hiddenAnalysisRun) { + return Promise.resolve( + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }), + ); + } return Promise.resolve( jsonResponse({ analysis_run_id: "run-demo-lineage", @@ -356,11 +366,17 @@ describe("App, authenticated", () => { return Promise.resolve(new Response(JSON.stringify(created), { status: 201 })); } if (url.endsWith("/api/analysis-runs")) { + analysisRunListCalls += 1; + const includeStaleLineageRow = !( + options?.hiddenAnalysisRun && analysisRunListCalls > 1 + ); return Promise.resolve( jsonResponse({ analysis_runs: [ ...(createdPendingLineage ? [createdPendingLineage] : []), - { + ...(includeStaleLineageRow + ? [ + { analysis_run_id: "run-demo-lineage", run_kind_code: "analysis_run_lineage", run_kind_label: "Lineage reconstruction", @@ -383,7 +399,9 @@ describe("App, authenticated", () => { code_revision_sha: "abcdef0123456789deadbeefcafebabe", configuration_sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - }, + }, + ] + : []), { analysis_run_id: "run-demo-tepp", run_kind_code: "analysis_run_tepp", @@ -1740,6 +1758,42 @@ describe("App, authenticated", () => { expect(teppButton).not.toHaveTextContent("reconstruction"); }); + it("tells the operator what to do when a listed run is no longer visible", async () => { + stubBackend({ hiddenAnalysisRun: true }); + render(); + + await screen.findByRole("list", { name: "Analysis runs" }); + await userEvent.click( + screen.getByRole("button", { + name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp", + }), + ); + + expect( + await screen.findByRole("alert"), + ).toHaveTextContent( + "This run is not on your list. Open a visible run from the home list, or request a lineage reconstruction for a corporation you already walk.", + ); + await waitFor(() => { + expect( + screen.queryByRole("button", { + name: "Open analysis run: Lineage reconstruction · Succeeded · Demo Corp", + }), + ).not.toBeInTheDocument(); + }); + expect( + screen.getByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Request a lineage reconstruction" }), + ).toBeInTheDocument(); + expect(screen.queryByText(/not visible/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/thread-group/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/knowledge_cutoff/i)).not.toBeInTheDocument(); + }); + it("does not tell a failed period report to connect the measurement service", async () => { stubBackend({ failedReportRun: true }); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 07088e9d4..2719ac881 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -60,6 +60,7 @@ import { } from "./api"; import { CitationChip } from "./components/CitationChip"; import { PopupCloseButton } from "./components/PopupCloseButton"; +import { StatusAlert } from "./components/StatusAlert"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { subgraphForPost } from "./lineageLayout"; @@ -1561,6 +1562,17 @@ function analysisRunDigestPrefix(digest: string): string { return digest.slice(0, ANALYSIS_RUN_DIGEST_PREFIX_LENGTH); } +/** + * Next action when detail 404s. Stay generic: do not name the thread or the cutoff. + * Naming either would confirm a hidden row (ADR 0018). + */ +function analysisRunHiddenNextAction(): string { + return ( + "This run is not on your list. Open a visible run from the home list, " + + "or request a lineage reconstruction for a corporation you already walk." + ); +} + /** * Next action when a cutoff title opens the live post (ADR 0016). * @@ -1653,14 +1665,19 @@ function AnalysisRunsPanel({ } catch (err) { setSelected(null); if (err instanceof BackendError && err.status === 404) { - setError("This analysis run is not visible."); + setError(analysisRunHiddenNextAction()); + try { + setRuns((await fetchAnalysisRuns(accessToken)).analysis_runs); + } catch { + // Keep the last authorized list if the re-read fails. + } return; } setError(String(err)); } } - if (error && runs === null) return

{error}

; + if (error && runs === null) return {error}; if (runs === null) return

Loading analysis runs...

; const corpusHint = selected ? analysisRunCorpusHint(selected) : null; @@ -1679,7 +1696,7 @@ function AnalysisRunsPanel({ {requesting ? "Recording the run..." : "Request a lineage reconstruction"} - {error &&

{error}

} + {error && {error}} {runs.length === 0 ? (

No analysis runs visible to this account yet. Request a lineage diff --git a/frontend/src/components/StatusAlert.stories.tsx b/frontend/src/components/StatusAlert.stories.tsx new file mode 100644 index 000000000..947aeedff --- /dev/null +++ b/frontend/src/components/StatusAlert.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StatusAlert } from "./StatusAlert"; + +const meta = { + title: "Chrome/StatusAlert", + component: StatusAlert, + args: { + children: + "This run is not on your list. Open a visible run from the home list, or request a lineage reconstruction for a corporation you already walk.", + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const HiddenAnalysisRun: Story = {}; + +export const ListLoadFailure: Story = { + args: { + children: "BackendError: 503 Service Unavailable", + }, +}; diff --git a/frontend/src/components/StatusAlert.test.tsx b/frontend/src/components/StatusAlert.test.tsx new file mode 100644 index 000000000..076dfe19b --- /dev/null +++ b/frontend/src/components/StatusAlert.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { StatusAlert } from "./StatusAlert"; + +describe("StatusAlert", () => { + it("announces the next action as an alert", () => { + render( + + This run is not on your list. Open a visible run from the home list, or request a lineage reconstruction for a corporation you already walk. + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent( + "This run is not on your list. Open a visible run from the home list, or request a lineage reconstruction for a corporation you already walk.", + ); + expect(screen.queryByText(/thread-group/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/knowledge_cutoff/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/StatusAlert.tsx b/frontend/src/components/StatusAlert.tsx new file mode 100644 index 000000000..df684a842 --- /dev/null +++ b/frontend/src/components/StatusAlert.tsx @@ -0,0 +1,17 @@ +export type StatusAlertProps = { + children: string; +}; + +/** + * Announces a fail-closed status so the operator hears the next action. + * + * Next action: read the sentence, then use the control it names + * (open a visible run, or request a lineage reconstruction). + */ +export function StatusAlert({ children }: StatusAlertProps) { + return ( +

+ {children} +

+ ); +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index e3510b83c..3e5a91af1 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -8,6 +8,7 @@ --color-accent-background: rgba(170, 59, 255, 0.1); --color-accent-border: rgba(170, 59, 255, 0.5); --color-chip-border: #3335; + --color-status-alert: #b91c1c; --space-chip-inline: 0.6rem; --space-chip-block: 0.1rem; --space-chip-gap: 0.3rem; @@ -28,5 +29,6 @@ --color-accent-background: rgba(192, 132, 252, 0.15); --color-accent-border: rgba(192, 132, 252, 0.5); --color-chip-border: #9ca3af; + --color-status-alert: #f87171; } } diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 1950c39f8..02d82b9d0 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.87.0" +__version__ = "0.87.4" diff --git a/pyproject.toml b/pyproject.toml index ecfe24877..6da3d2205 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.87.0" +version = "0.87.4" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_analysis_run_authorization.py b/tests/test_analysis_run_authorization.py index 64a0504f7..8591ed143 100644 --- a/tests/test_analysis_run_authorization.py +++ b/tests/test_analysis_run_authorization.py @@ -11,6 +11,8 @@ import pytest from psycopg2 import sql +from backend.app.analysis_run_ingestion import _VISIBLE_RUN_SQL + _ROOT = Path(__file__).resolve().parents[1] _INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" _REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" @@ -101,6 +103,37 @@ def _insert_corp(cursor, code: str, name: str) -> str: return str(cursor.fetchone()[0]) +def _insert_process_unit( + cursor, corporate_entity_id: str, code: str, name: str +) -> str: + """Insert one synthetic process unit under a corporate entity.""" + cursor.execute( + """ + insert into process_unit + (corporate_entity_id, process_unit_code, process_unit_name) + values (%s, %s, %s) + returning process_unit_id + """, + (corporate_entity_id, code, name), + ) + return str(cursor.fetchone()[0]) + + +def _ensure_post_lookups(cursor) -> None: + """Insert the visibility and VOC codes the cutoff fixtures need.""" + cursor.execute( + """ + insert into common_lookup_value + (lookup_category, lookup_code, lookup_label) + values + ('post_visibility', 'public', 'Public'), + ('post_visibility', 'private', 'Private'), + ('voc_type', 'voc', 'Voice of Customer') + on conflict (lookup_code) do nothing + """ + ) + + def _complete_run( cursor, *, @@ -109,6 +142,9 @@ def _complete_run( idempotency_key: str, scope_kind: str, corporate_entity_id: str | None = None, + process_unit_id: str | None = None, + scope_key: str | None = None, + knowledge_cutoff: str = "2026-01-12T12:00:00Z", ) -> str: """Insert one succeeded run with one document-count aggregate.""" cursor.execute( @@ -139,11 +175,18 @@ def _complete_run( configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) values (%s, 'analysis_run_lineage', %s, %s, - '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, + %s, 'lineage-run-v1', %s, %s, '2026-01-12T12:30:00Z') returning analysis_run_id """, - (snapshot_id, idempotency_key, account_id, "b" * 64, "c" * 40), + ( + snapshot_id, + idempotency_key, + account_id, + knowledge_cutoff, + "b" * 64, + "c" * 40, + ), ) run_id = str(cursor.fetchone()[0]) if scope_kind == "analysis_scope_corporate_entity": @@ -155,6 +198,24 @@ def _complete_run( """, (run_id, scope_kind, corporate_entity_id), ) + elif scope_kind == "analysis_scope_thread_group": + cursor.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, scope_key) + values (%s, %s, %s) + """, + (run_id, scope_kind, scope_key), + ) + elif scope_kind == "analysis_scope_process_unit": + cursor.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, process_unit_id) + values (%s, %s, %s) + """, + (run_id, scope_kind, process_unit_id), + ) else: cursor.execute( """ @@ -180,33 +241,64 @@ def _complete_run( return run_id +def _product_visibility_predicate() -> str: + """Translate asyncpg ``$1``/``$2`` reuse into psycopg2 named placeholders. + + The product SQL binds the account once as ``$1`` and affiliated + entity ids once as ``$2``, then reuses both. Positional ``%s`` + cannot do that: interpolating the cutoff fragment twice yields + five placeholders and only two arguments. + """ + return ( + _VISIBLE_RUN_SQL.replace("$2::uuid[]", "%(entity_ids)s::uuid[]").replace( + "$1", "%(account_id)s" + ) + ) + + def _visible_ids(cursor, account_id: str, entity_ids: list[str]) -> set[str]: """Apply the same visibility predicate the product API uses.""" cursor.execute( - """ + f""" select run.analysis_run_id from analysis_run run join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id - where - run.requested_by_account_id = %s - or ( - scope.scope_kind_code = 'analysis_scope_corporate_entity' - and scope.corporate_entity_id = any(%s::uuid[]) - ) - or ( - scope.scope_kind_code = 'analysis_scope_process_unit' - and exists ( - select 1 from account_affiliation aff - where aff.user_account_id = %s - and aff.process_unit_id = scope.process_unit_id - ) - ) + where {_product_visibility_predicate()} """, - (account_id, entity_ids, account_id), + {"account_id": account_id, "entity_ids": entity_ids}, ) return {str(row[0]) for row in cursor.fetchall()} +def test_visible_ids_helper_reuses_named_account_and_entity_binds() -> None: + """asyncpg $1/$2 reuse must not become positional %s.""" + + predicate = _product_visibility_predicate() + leftover = ( + predicate.replace("%(account_id)s", "").replace("%(entity_ids)s", "") + ) + assert "%(account_id)s" in predicate + assert "%(entity_ids)s::uuid[]" in predicate + assert "%s" not in leftover + assert predicate.count("%(account_id)s") == 2 + assert predicate.count("%(entity_ids)s") == 3 + + +def test_requester_ownership_cannot_bypass_thread_group_cutoff() -> None: + """ADR 0018: requester ownership stays inside the first conjunct.""" + + sql = " ".join(_VISIBLE_RUN_SQL.split()) + ownership = "run.requested_by_account_id = $1" + first, separator, second = sql.partition(") and (") + assert ownership in sql + assert "p.created_at <= run.knowledge_cutoff" in sql + assert separator + assert ownership in first + assert ownership not in second + assert "scope.scope_kind_code <> 'analysis_scope_thread_group'" in second + assert "p.created_at <= run.knowledge_cutoff" in second + + def test_hidden_scope_does_not_leak_through_all_visible_or_other_corp(authz_db) -> None: """A Demo-Corp viewer never sees another tenant's run or its aggregates.""" with authz_db.cursor() as cursor: @@ -254,3 +346,146 @@ def test_hidden_scope_does_not_leak_through_all_visible_or_other_corp(authz_db) assert hidden_all_visible in outsider_visible assert hidden_other_corp in outsider_visible assert own_run not in outsider_visible + + +def test_requester_owned_thread_group_run_needs_in_cutoff_post(authz_db) -> None: + """A January thread-group run you requested stays hidden without an in-cutoff post.""" + + with authz_db.cursor() as cursor: + viewer = _insert_account(cursor, "requester") + own_corp = _insert_corp(cursor, "DEMO-CORP-CUTOFF", "Demo Corp") + _ensure_post_lookups(cursor) + cursor.execute( + """ + insert into account_affiliation (user_account_id, corporate_entity_id) + values (%s, %s) + """, + (viewer, own_corp), + ) + cursor.execute( + """ + insert into source_post + (author_account_id, corporate_entity_id, post_title, post_body, + voc_type_code, visibility_code, thread_group_key, created_at) + values + (%s, %s, 'Late own thread', 'Written after the January cutoff.', + 'voc', 'public', 'late-own-thread', '2026-01-20T12:00:00Z'), + (%s, %s, 'In-cutoff own thread', 'Visible at the January cutoff.', + 'voc', 'public', 'in-cutoff-own-thread', '2026-01-10T12:00:00Z') + """, + (viewer, own_corp, viewer, own_corp), + ) + hidden_own = _complete_run( + cursor, + account_id=viewer, + digest="1" * 64, + idempotency_key="own-late-thread", + scope_kind="analysis_scope_thread_group", + scope_key="late-own-thread", + ) + visible_own = _complete_run( + cursor, + account_id=viewer, + digest="2" * 64, + idempotency_key="own-in-cutoff-thread", + scope_kind="analysis_scope_thread_group", + scope_key="in-cutoff-own-thread", + ) + visible = _visible_ids(cursor, viewer, [own_corp]) + assert hidden_own not in visible + assert visible_own in visible + + +def test_thread_group_cutoff_honors_private_and_empty_affiliation(authz_db) -> None: + """Private own-corp in-cutoff lists; other-corp private and empty $2 do not.""" + + with authz_db.cursor() as cursor: + viewer = _insert_account(cursor, "private-viewer") + other = _insert_account(cursor, "other-author") + own_corp = _insert_corp(cursor, "DEMO-CORP-PRIVATE", "Demo Corp") + other_corp = _insert_corp(cursor, "OTHER-CORP-PRIVATE", "Other Corp") + _ensure_post_lookups(cursor) + cursor.execute( + """ + insert into account_affiliation (user_account_id, corporate_entity_id) + values (%s, %s) + """, + (viewer, own_corp), + ) + cursor.execute( + """ + insert into source_post + (author_account_id, corporate_entity_id, post_title, post_body, + voc_type_code, visibility_code, thread_group_key, created_at) + values + (%s, %s, 'Private own thread', 'Visible only to Demo Corp.', + 'voc', 'private', 'private-own-thread', '2026-01-10T12:00:00Z'), + (%s, %s, 'Private other thread', 'Other corp only.', + 'voc', 'private', 'private-other-thread', '2026-01-10T12:00:00Z'), + (%s, %s, 'Public empty-aff thread', 'Public in-cutoff post.', + 'voc', 'public', 'public-empty-aff-thread', '2026-01-10T12:00:00Z') + """, + (viewer, own_corp, other, other_corp, viewer, own_corp), + ) + private_own = _complete_run( + cursor, + account_id=viewer, + digest="3" * 64, + idempotency_key="private-own-thread", + scope_kind="analysis_scope_thread_group", + scope_key="private-own-thread", + ) + private_other = _complete_run( + cursor, + account_id=viewer, + digest="4" * 64, + idempotency_key="private-other-thread", + scope_kind="analysis_scope_thread_group", + scope_key="private-other-thread", + ) + public_empty = _complete_run( + cursor, + account_id=viewer, + digest="5" * 64, + idempotency_key="public-empty-aff-thread", + scope_kind="analysis_scope_thread_group", + scope_key="public-empty-aff-thread", + ) + affiliated = _visible_ids(cursor, viewer, [own_corp]) + assert private_own in affiliated + assert private_other not in affiliated + assert public_empty in affiliated + + unaffiliated = _visible_ids(cursor, viewer, []) + assert private_own not in unaffiliated + assert private_other not in unaffiliated + assert public_empty in unaffiliated + + +def test_process_unit_run_lists_without_thread_group_cutoff(authz_db) -> None: + """A process-unit run the caller already walks is not gated by cutoff.""" + + with authz_db.cursor() as cursor: + viewer = _insert_account(cursor, "unit-viewer") + own_corp = _insert_corp(cursor, "DEMO-CORP-UNIT", "Demo Corp") + process_unit_id = _insert_process_unit( + cursor, own_corp, "DEMO-PU-CUTOFF", "Demo Process Unit" + ) + cursor.execute( + """ + insert into account_affiliation + (user_account_id, corporate_entity_id, process_unit_id) + values (%s, %s, %s) + """, + (viewer, own_corp, process_unit_id), + ) + unit_run = _complete_run( + cursor, + account_id=viewer, + digest="6" * 64, + idempotency_key="own-process-unit", + scope_kind="analysis_scope_process_unit", + process_unit_id=process_unit_id, + ) + visible = _visible_ids(cursor, viewer, [own_corp]) + assert unit_run in visible diff --git a/tests/test_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py index d2994e2c4..171e44339 100644 --- a/tests/test_ingestion_transaction_contracts.py +++ b/tests/test_ingestion_transaction_contracts.py @@ -199,6 +199,7 @@ async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None: return {"korean_summary": "합성 요약"} if compact.startswith("select person_id from cataloged_person"): assert self.in_transaction + assert "order by created_at, person_id" in compact return None raise AssertionError(f"unexpected fetchrow query: {compact}") @@ -483,6 +484,77 @@ def test_release_notes_describe_balanced_outer_emphasis_stripping() -> None: assert "preserves Markdown emphasis in field values" not in content +def test_release_notes_do_not_offer_thread_group_request() -> None: + """ADR 0017 create is still corporate-scope; copy must not invent a retry path.""" + + changelog = " ".join( + (Path(__file__).resolve().parents[1] / "CHANGELOG.md") + .read_text(encoding="utf-8") + .split() + ) + adr = " ".join( + ( + Path(__file__).resolve().parents[1] + / "docs" + / "adr" + / "0018-related-nodes-team-org-walk.md" + ) + .read_text(encoding="utf-8") + .split() + ) + assert "then request again" not in changelog + assert "then request again" not in adr + assert "Request still records a corporate-scope lineage run" in changelog + assert "Request still records a corporate-scope lineage run" in adr + + +def test_hidden_run_copy_stays_generic_and_names_the_next_action() -> None: + """ADR 0014/0018: a 404 must not confirm why a row is hidden.""" + + app = ( + Path(__file__).resolve().parents[1] / "frontend" / "src" / "App.tsx" + ).read_text(encoding="utf-8") + alert = ( + Path(__file__).resolve().parents[1] + / "frontend" + / "src" + / "components" + / "StatusAlert.tsx" + ).read_text(encoding="utf-8") + agents = ( + Path(__file__).resolve().parents[1] / "AGENTS.md" + ).read_text(encoding="utf-8") + assert "This analysis run is not visible." not in app + assert "This run is not on your list. Open a visible run from the home list," in app + assert ( + "or request a lineage reconstruction for a corporation you already walk." + in app + ) + assert "do not name the thread or the cutoff" in app + assert "setRuns((await fetchAnalysisRuns(accessToken)).analysis_runs)" in app + assert 'role="alert"' in alert + assert "{error}" in app + assert "even when the signed-in account requested" in agents + assert "do not name the thread or the cutoff" in agents + assert "re-read the authorized list" in agents + + +def test_person_role_join_orders_catalog_homonyms() -> None: + """ADR 0009: two same-named people must not attach by unordered LIMIT 1.""" + + source = ( + Path(__file__).resolve().parents[1] + / "backend" + / "app" + / "post_summary_ingestion.py" + ).read_text(encoding="utf-8") + assert "order by created_at, person_id" in source + assert ( + "select person_id from cataloged_person where person_name = $1 limit 1" + not in source + ) + + def test_role_catalog_identity_is_stored_on_the_role_row() -> None: """ADR 0019: fetch must not reconstruct organization identity by name.""" root = Path(__file__).resolve().parents[1] diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py index 81e63a75f..da52b19e3 100644 --- a/tests/test_person_mention_projection.py +++ b/tests/test_person_mention_projection.py @@ -43,6 +43,7 @@ ) from lineageweave.post_summary import ( ACTOR_TYPE_ORGANIZATION, + ACTOR_TYPE_PERSON, PostSummary, RoleResponsibility, ) @@ -462,6 +463,62 @@ def test_team_only_posts_walk_related_nodes(projection_database: str) -> None: asyncio.run(_exercise_team_only_related_walk(database_dsn, post_id)) +async def _exercise_person_homonym_role_binding( + database_dsn: str, + post_id: str, +) -> None: + """A later same-named catalog person must not steal the R&R mention.""" + + connection = await asyncpg.connect(database_dsn) + try: + earlier_id = str( + await connection.fetchval( + """ + insert into cataloged_person + (person_name, person_side_code, created_at) + values ('Homonym Person', 'our_side', '2026-01-01T00:00:00Z') + returning person_id + """ + ) + ) + later_id = str( + await connection.fetchval( + """ + insert into cataloged_person + (person_name, person_side_code, created_at) + values ('Homonym Person', 'counterparty', '2026-02-01T00:00:00Z') + returning person_id + """ + ) + ) + await persist_post_summary( + connection, + post_id, + PostSummary( + korean_summary="동명이인 담당자가 일정을 확인했다.", + roles_and_responsibilities=( + RoleResponsibility( + actor_name="Homonym Person", + responsibility="일정 확인", + actor_type_code=ACTOR_TYPE_PERSON, + ), + ), + ), + ) + mention_ids = [ + str(row["person_id"]) + for row in await connection.fetch( + "select person_id from post_summary_person_mention " + "where post_id = $1", + post_id, + ) + ] + assert mention_ids == [earlier_id] + assert later_id not in mention_ids + finally: + await connection.close() + + async def _exercise_homonym_organization_role_binding( database_dsn: str, post_id: str, @@ -535,6 +592,15 @@ async def resolve_mentioned_organization(*_args, **_kwargs) -> str: await connection.close() +def test_person_role_binds_the_earliest_same_named_catalog_id( + projection_database: str, +) -> None: + """ADR 0009: two catalog people can share a name; R&R keeps the earliest id.""" + + database_dsn, post_id, _summary_person_id = projection_database.split("|") + asyncio.run(_exercise_person_homonym_role_binding(database_dsn, post_id)) + + def test_homonym_organization_role_binds_the_resolved_catalog_id( projection_database: str, ) -> None: diff --git a/uv.lock b/uv.lock index 6915a3531..d5bce76c3 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.87.0" +version = "0.87.4" source = { virtual = "." } dependencies = [ { name = "certifi" },