diff --git a/CHANGELOG.d/0.86.3-requester-thread-cutoff.md b/CHANGELOG.d/0.86.3-requester-thread-cutoff.md new file mode 100644 index 000000000..50426f9cf --- /dev/null +++ b/CHANGELOG.d/0.86.3-requester-thread-cutoff.md @@ -0,0 +1,3 @@ +A thread-group run you requested stays off the home list unless that +thread has an in-cutoff visible post. Same-named R&R people bind the +earliest catalog row. diff --git a/CHANGELOG.md b/CHANGELOG.md index 13aeb02f9..471028da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.86.3] - 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). Open a thread that has an in-cutoff + visible post, then request again. 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.86.2] - 2026-08-16 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index a11127584..9b2371118 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,3 +20,7 @@ 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. 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..013fd6899 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: 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 21c71bc9a..b4f7c2908 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1704,6 +1704,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/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md index ae0a1c331..6a2193f44 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,9 @@ 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. Open a thread that has an in-cutoff visible post, then request + again. ## References diff --git a/frontend/package.json b/frontend/package.json index fb52f7948..b5209226e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.2", + "version": "0.86.3", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index efe84890b..b84afb519 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.2" +__version__ = "0.86.3" diff --git a/pyproject.toml b/pyproject.toml index 6e41c7ca7..12222bdad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.2" +version = "0.86.3" 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 730825c14..be71e6357 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" @@ -107,6 +109,8 @@ def _complete_run( idempotency_key: str, scope_kind: str, corporate_entity_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( @@ -137,11 +141,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": @@ -153,6 +164,15 @@ 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), + ) else: cursor.execute( """ @@ -180,31 +200,30 @@ def _complete_run( def _visible_ids(cursor, account_id: str, entity_ids: list[str]) -> set[str]: """Apply the same visibility predicate the product API uses.""" + predicate = _VISIBLE_RUN_SQL.replace("$2::uuid[]", "%s::uuid[]").replace( + "$1", "%s" + ) 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 {predicate} """, - (account_id, entity_ids, account_id), + (account_id, entity_ids), ) return {str(row[0]) for row in cursor.fetchall()} +def test_requester_ownership_cannot_bypass_thread_group_cutoff() -> None: + """ADR 0018: requester ownership is not a standalone OR past the clock.""" + + sql = " ".join(_VISIBLE_RUN_SQL.split()) + assert "p.created_at <= run.knowledge_cutoff" in sql + assert "scope.scope_kind_code <> 'analysis_scope_thread_group'" in sql + assert "run.requested_by_account_id = $1" in sql + + 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: @@ -252,3 +271,60 @@ 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") + cursor.execute( + """ + insert into common_lookup_value + (lookup_category, lookup_code, lookup_label) + values + ('post_visibility', 'public', 'Public'), + ('voc_type', 'voc', 'Voice of Customer') + on conflict (lookup_code) do nothing + """ + ) + 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 diff --git a/tests/test_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py index d2994e2c4..4f208497e 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,22 @@ def test_release_notes_describe_balanced_outer_emphasis_stripping() -> None: assert "preserves Markdown emphasis in field values" not in content +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 e1d2860cf..179628fdd 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.2" +version = "0.86.3" source = { virtual = "." } dependencies = [ { name = "certifi" },