From 8744a4741dfec8c3f1fce7d524752fc47c3d44ed Mon Sep 17 00:00:00 2001 From: witbrock Date: Wed, 16 Sep 2026 16:03:53 -0400 Subject: [PATCH] Scope imported raw documents to source account and workspace --- .../external_conversation_import.md | 7 ++++ src/backend/services/chat_history_service.py | 2 + .../external_conversation_import_service.py | 12 ++++++ ...st_external_conversation_import_service.py | 42 +++++++++++++++++++ 4 files changed, 63 insertions(+) diff --git a/docs/engineering/external_conversation_import.md b/docs/engineering/external_conversation_import.md index 59bab94f..507d6520 100644 --- a/docs/engineering/external_conversation_import.md +++ b/docs/engineering/external_conversation_import.md @@ -148,6 +148,13 @@ report rather than hidden. ## Display in Conversations +Raw-document identity includes provider account and workspace when present, +matching the conversation's source scope. This prevents reused provider session +IDs (including Gemini A2A runs with the same start time) from sharing one mutable +raw-document pointer across workspaces. Existing raw artefacts remain retained; +reconciliation can refresh an affected transcript's raw-document reference +without replacing its history or dates. + Imported conversations are hidden from the Conversations tray and its search results by default. Select **Show imported** in the tray filters to include them for the current page session. This uses the existing diff --git a/src/backend/services/chat_history_service.py b/src/backend/services/chat_history_service.py index 36b8ea51..3cdefb9e 100644 --- a/src/backend/services/chat_history_service.py +++ b/src/backend/services/chat_history_service.py @@ -4833,6 +4833,8 @@ def _event_fingerprint(entry: Mapping[str, Any]) -> str: == stored_manifest.get("parser_version") and existing_manifest.get("raw_file_copy_concept_id") == stored_manifest.get("raw_file_copy_concept_id") + and existing_manifest.get("raw_document_concept_id") + == stored_manifest.get("raw_document_concept_id") ): existing_synchronisation = existing_manifest.get("synchronisation") existing_divergence = ( diff --git a/src/backend/services/external_conversation_import_service.py b/src/backend/services/external_conversation_import_service.py index 35d26f21..070bbb10 100644 --- a/src/backend/services/external_conversation_import_service.py +++ b/src/backend/services/external_conversation_import_service.py @@ -1548,6 +1548,18 @@ def _build_source_record( f"external-import://custodian/{custodian_scope}/" f"{package.provider}/{package.source_session_id}" ) + # Provider session IDs can repeat across accounts/workspaces. Raw custody + # must distinguish the same source scopes as the conversation projection. + # Hash exact values before the ingestion layer normalises paths to lowercase. + if package.source_account or package.source_workspace: + source_scope = hashlib.sha256( + json.dumps( + [package.source_account or "", package.source_workspace or ""], + ensure_ascii=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + canonical_source_path += f"/scope/{source_scope}" return SessionRecord( environment=package.provider, source_session_id=package.source_session_id, diff --git a/tests/backend/test_external_conversation_import_service.py b/tests/backend/test_external_conversation_import_service.py index bec39e77..f17ab4e4 100644 --- a/tests/backend/test_external_conversation_import_service.py +++ b/tests/backend/test_external_conversation_import_service.py @@ -112,6 +112,36 @@ def parse(start: str, *, append: bool = False, session_id: str = "a2a-server"): ) +def test_raw_custody_distinguishes_gemini_workspace_and_account(tmp_path): + def record(workspace, account=None, *, append=False): + messages = [{"type": "user", "content": "Inspect this workspace"}] + if append: + messages.append({"type": "gemini", "content": "Inspected"}) + path = tmp_path / "same-filename.jsonl" + path.write_bytes(_jsonl({ + "sessionId": "a2a-server", + "startTime": "2026-06-28T16:27:59Z", + "projectHash": workspace, + "messages": messages, + })) + package = external_conversation_import_service.parse_external_conversation_file( + path, provider_hint="gemini", source_account=account + ) + return external_conversation_import_service._build_source_record( + path=path, package=package, custodian_user_id="#V#user", namespace="#V#user" + ) + + first = record("workspace") + grown = record("workspace", append=True) + other_workspace = record("Workspace") + other_account = record("workspace", "another-account") + assert first.source_session_id == other_workspace.source_session_id + assert first.document_concept_id == grown.document_concept_id + assert len({first.document_concept_id, other_workspace.document_concept_id, + other_account.document_concept_id}) == 3 + assert "another-account" not in other_account.canonical_source_path + + def test_codex_parser_projects_visible_messages_without_importing_instructions(): package = parse_external_conversation_bytes( _jsonl( @@ -389,6 +419,18 @@ def test_chat_projection_is_read_only_idempotent_and_actor_scoped(monkeypatch): manifest=manifest, ) assert repeated["action"] == "unchanged" + # A corrected raw-document reference must refresh even when bytes, parsed + # events and the file-copy reference did not change. Keep transcript intact. + history_before = collection.find_one({"session_id": "external-session"})["history"] + repaired = chat_history_service.upsert_external_conversation_projection( + user_id="#V#user", session_id="external-session", session_name="Imported session", + namespace="#V#user@org", organisation_concept_id="#V#org", role_in_org="member", + history=history, manifest={**manifest, "raw_document_concept_id": "#V#scoped-source"}, + ) + assert repaired["action"] == "refreshed" + after = collection.find_one({"session_id": "external-session"}) + assert after["external_conversation_import"]["raw_document_concept_id"] == "#V#scoped-source" + assert after["history"] == history_before assert ( collection.find_one({"session_id": "external-session"})["session_name"] == "Imported session"