From a2fec1a37c5172ab2d30d2ce989dd26b34d4fae9 Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 8 Aug 2026 19:40:45 +0200 Subject: [PATCH 1/3] fix(ingest): preserve origin title provenance Assign typed origin provenance only to provider-authored titles from Grok, Antigravity, browser capture, and Hermes. Keep heuristic filename and page-title fallbacks untrusted. Add parser assertions and a shared parser-to-public-surface regression route with a provenance-removal mutation.\n\nRef polylogue-o5smo. --- polylogue/sources/parsers/antigravity.py | 3 +- polylogue/sources/parsers/browser_capture.py | 10 +- polylogue/sources/parsers/grok.py | 6 +- polylogue/sources/parsers/hermes_state.py | 6 +- .../unit/sources/parsers/test_antigravity.py | 5 +- tests/unit/sources/parsers/test_grok.py | 4 +- .../unit/sources/parsers/test_hermes_state.py | 12 +- .../parsers/test_origin_regression_pack.py | 17 ++- tests/unit/sources/test_browser_capture.py | 16 ++- .../storage/test_title_provenance_origins.py | 136 ++++++++++++++++++ 10 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 tests/unit/storage/test_title_provenance_origins.py diff --git a/polylogue/sources/parsers/antigravity.py b/polylogue/sources/parsers/antigravity.py index 6efb6da8ef..bca1ff58f7 100644 --- a/polylogue/sources/parsers/antigravity.py +++ b/polylogue/sources/parsers/antigravity.py @@ -19,7 +19,7 @@ from polylogue.archive.message.artifacts import classify_material_origin from polylogue.archive.message.roles import Role from polylogue.archive.message.types import MessageType -from polylogue.core.enums import BlockType, Provider +from polylogue.core.enums import BlockType, Provider, TitleSource from polylogue.core.json import JSONDocument, dumps_bytes, loads from .base import ( @@ -308,6 +308,7 @@ def parse_markdown_export( source_name=Provider.ANTIGRAVITY, provider_session_id=summary.cascade_id, title=summary.title, + title_source=TitleSource.ORIGIN if summary.title else None, created_at=None, updated_at=summary.last_modified_time, messages=messages, diff --git a/polylogue/sources/parsers/browser_capture.py b/polylogue/sources/parsers/browser_capture.py index b3cb63c6f9..bad3d3e19f 100644 --- a/polylogue/sources/parsers/browser_capture.py +++ b/polylogue/sources/parsers/browser_capture.py @@ -22,7 +22,7 @@ BrowserCaptureTurn, looks_like_browser_capture, ) -from polylogue.core.enums import BlockType, Provider, SessionKind +from polylogue.core.enums import BlockType, Provider, SessionKind, TitleSource from polylogue.core.timestamps import parse_timestamp from polylogue.sources.parsers.base_models import ( ParsedAttachment, @@ -342,6 +342,7 @@ def _merge_envelope_native_metadata(parsed: ParsedSession, envelope: BrowserCapt fallback_titles = {None, "", parsed.provider_session_id, envelope.session.provider_session_id} if parsed.title in fallback_titles and envelope.session.title: updates["title"] = envelope.session.title + updates["title_source"] = TitleSource.ORIGIN if parsed.created_at is None and envelope.session.created_at is not None: updates["created_at"] = envelope.session.created_at if parsed.updated_at is None and envelope.session.updated_at is not None: @@ -465,6 +466,7 @@ def _parse_claude_fallback_envelope( source_name=Provider.CLAUDE_AI, provider_session_id=provider_session_id, title=envelope.session.title or envelope.provenance.page_title or provider_session_id, + title_source=TitleSource.ORIGIN if envelope.session.title else None, session_kind=_session_kind_for_browser_capture(envelope, provider_session_id), created_at=created_at, updated_at=updated_at, @@ -640,7 +642,10 @@ def parse(payload: object, fallback_id: str) -> ParsedSession: return _merge_envelope_session_events( _apply_browser_capture_session_kind( - _merge_envelope_attachments(parse_chatgpt(raw_provider_payload, provider_session_id), envelope), + _merge_envelope_attachments( + _merge_envelope_native_metadata(parse_chatgpt(raw_provider_payload, provider_session_id), envelope), + envelope, + ), envelope, provider_session_id, has_native_payload=True, @@ -729,6 +734,7 @@ def parse(payload: object, fallback_id: str) -> ParsedSession: source_name=provider, provider_session_id=provider_session_id, title=envelope.session.title or envelope.provenance.page_title or provider_session_id, + title_source=TitleSource.ORIGIN if envelope.session.title else None, session_kind=_session_kind_for_browser_capture(envelope, provider_session_id), created_at=envelope.session.created_at, updated_at=envelope.session.updated_at, diff --git a/polylogue/sources/parsers/grok.py b/polylogue/sources/parsers/grok.py index d33ac9e731..8fd8c18aa9 100644 --- a/polylogue/sources/parsers/grok.py +++ b/polylogue/sources/parsers/grok.py @@ -40,7 +40,7 @@ from polylogue.archive.message.artifacts import classify_material_origin from polylogue.archive.message.roles import Role from polylogue.archive.message.types import MessageType -from polylogue.core.enums import BlockType, Provider +from polylogue.core.enums import BlockType, Provider, TitleSource from polylogue.core.timestamps import canonical_timestamp_text from .base import ( @@ -132,7 +132,8 @@ def parse_conversation(payload: Mapping[str, object], fallback_id: str) -> Parse responses = responses_raw if isinstance(responses_raw, list) else [] title_raw = conversation.get("title") - title = title_raw if isinstance(title_raw, str) and title_raw else fallback_id + provider_title = title_raw if isinstance(title_raw, str) and title_raw else None + title = provider_title or fallback_id created_at = _timestamp_text(conversation.get("create_time")) messages: list[ParsedMessage] = [] @@ -186,6 +187,7 @@ def parse_conversation(payload: Mapping[str, object], fallback_id: str) -> Parse source_name=Provider.GROK, provider_session_id=fallback_id, title=title, + title_source=TitleSource.ORIGIN if provider_title else None, created_at=created_at, updated_at=updated_at, messages=messages, diff --git a/polylogue/sources/parsers/hermes_state.py b/polylogue/sources/parsers/hermes_state.py index d21d2ce4d0..48a8d0a939 100644 --- a/polylogue/sources/parsers/hermes_state.py +++ b/polylogue/sources/parsers/hermes_state.py @@ -18,7 +18,7 @@ from polylogue.archive.message.roles import Role from polylogue.archive.session.branch_type import BranchType -from polylogue.core.enums import BlockType, MaterialOrigin, Provider +from polylogue.core.enums import BlockType, MaterialOrigin, Provider, TitleSource from polylogue.core.json import JSONDocument, json_document from .base import ParsedContentBlock, ParsedMessage, ParsedSession, ParsedSessionEvent, fill_linear_parent_chain @@ -695,10 +695,12 @@ def _parse_session_row( (message.provider_message_id for message in reversed(messages) if message.is_active_path), None, ) + provider_title = _optional_text(_row_value(row, "title")) return ParsedSession( source_name=Provider.HERMES, provider_session_id=session_id, - title=_optional_text(_row_value(row, "title")) or raw_session_id, + title=provider_title or raw_session_id, + title_source=TitleSource.ORIGIN if provider_title else None, created_at=_epoch_iso(row["started_at"]), updated_at=_epoch_iso(_row_value(row, "ended_at")) or _latest_message_timestamp(messages), messages=messages, diff --git a/tests/unit/sources/parsers/test_antigravity.py b/tests/unit/sources/parsers/test_antigravity.py index eb6148cde8..2417af5d26 100644 --- a/tests/unit/sources/parsers/test_antigravity.py +++ b/tests/unit/sources/parsers/test_antigravity.py @@ -3,7 +3,7 @@ from pathlib import Path from polylogue.archive.message.roles import Role -from polylogue.core.enums import BlockType, Provider +from polylogue.core.enums import BlockType, Provider, TitleSource from polylogue.core.json import JSONDocument from polylogue.pipeline.ids import session_revision_projection from polylogue.sources.parsers.antigravity import ( @@ -43,6 +43,7 @@ def test_parse_markdown_export_splits_known_sections() -> None: assert session.source_name is Provider.ANTIGRAVITY assert session.provider_session_id == "cascade-1" assert session.title == "Focused checks" + assert session.title_source is TitleSource.ORIGIN assert session.updated_at == "2026-03-05T04:21:34Z" assert [message.role for message in session.messages] == [Role.USER, Role.ASSISTANT] assert [message.text for message in session.messages] == [ @@ -113,6 +114,7 @@ def test_parse_markdown_export_falls_back_to_single_export_message() -> None: assert session.messages[0].position == 0 assert session.messages[0].is_active_leaf is True assert session.active_leaf_message_provider_id == session.messages[0].provider_message_id + assert session.title_source is None def test_parse_brain_metadata_reads_adjacent_artifact(tmp_path: Path) -> None: @@ -134,6 +136,7 @@ def test_parse_brain_metadata_reads_adjacent_artifact(tmp_path: Path) -> None: assert session.source_name is Provider.ANTIGRAVITY assert session.provider_session_id == "session-1:implementation_plan.md" assert session.title == "Implementation Plan" + assert session.title_source is None assert session.updated_at == "2026-01-07T19:08:15.216541610Z" assert session.messages[0].role is Role.ASSISTANT assert session.messages[0].text == "# Implementation Plan\n\nDo the work.\n" diff --git a/tests/unit/sources/parsers/test_grok.py b/tests/unit/sources/parsers/test_grok.py index 8073c4dc47..bfde8a2802 100644 --- a/tests/unit/sources/parsers/test_grok.py +++ b/tests/unit/sources/parsers/test_grok.py @@ -3,7 +3,7 @@ from typing import Any from polylogue.archive.message.roles import Role -from polylogue.core.enums import BlockType, Origin, Provider +from polylogue.core.enums import BlockType, Origin, Provider, TitleSource from polylogue.core.sources import origin_from_provider from polylogue.pipeline.ids import session_revision_projection from polylogue.sources.dispatch import detect_provider, parse_payload @@ -89,6 +89,7 @@ def test_parse_conversation_nested_response_shape() -> None: assert session.source_name is Provider.GROK assert session.provider_session_id == "grok-1" assert session.title == "Debugging a React hook" + assert session.title_source is TitleSource.ORIGIN assert session.created_at == "2024-04-01T19:33:20+00:00" assert len(session.messages) == 2 assert [m.role for m in session.messages] == [Role.USER, Role.ASSISTANT] @@ -118,6 +119,7 @@ def test_parse_conversation_missing_title_falls_back_to_id() -> None: payload: dict[str, Any] = {"conversation": {}, "responses": []} session = grok.parse_conversation(payload, "fallback-id") assert session.title == "fallback-id" + assert session.title_source is None def test_parse_conversation_empty_responses_yields_no_messages() -> None: diff --git a/tests/unit/sources/parsers/test_hermes_state.py b/tests/unit/sources/parsers/test_hermes_state.py index 3bfec3f0c5..86d841a701 100644 --- a/tests/unit/sources/parsers/test_hermes_state.py +++ b/tests/unit/sources/parsers/test_hermes_state.py @@ -14,7 +14,7 @@ import sqlite3 from pathlib import Path -from polylogue.core.enums import BlockType +from polylogue.core.enums import BlockType, TitleSource from polylogue.sources.parsers.base import ParsedContentBlock from polylogue.sources.parsers.hermes_state import parse_state_db @@ -83,6 +83,16 @@ def test_exit_code_zero_is_not_an_error(tmp_path: Path) -> None: assert blocks[0].exit_code == 0 +def test_state_db_explicit_title_is_provider_provenance(tmp_path: Path) -> None: + path = tmp_path / "state.db" + _write_state_db(path, tool_contents=[json.dumps({"output": "ok", "exit_code": 0})]) + + sessions = parse_state_db(path) + + assert sessions[0].title == "Outcome fixture" + assert sessions[0].title_source is TitleSource.ORIGIN + + def test_nonzero_exit_code_is_an_error(tmp_path: Path) -> None: blocks = _tool_result_blocks( tmp_path / "state.db", tool_contents=[json.dumps({"output": "boom", "exit_code": 127})] diff --git a/tests/unit/sources/parsers/test_origin_regression_pack.py b/tests/unit/sources/parsers/test_origin_regression_pack.py index 106759e021..c9088d109f 100644 --- a/tests/unit/sources/parsers/test_origin_regression_pack.py +++ b/tests/unit/sources/parsers/test_origin_regression_pack.py @@ -56,7 +56,7 @@ import pytest from polylogue.archive.message.roles import Role -from polylogue.core.enums import BlockType, Origin, Provider +from polylogue.core.enums import BlockType, Origin, Provider, TitleSource from polylogue.core.json import JSONDocument from polylogue.core.sources import origin_from_provider from polylogue.sources.dispatch import detect_provider, parse_payload @@ -132,6 +132,7 @@ class OriginFixture: # Optional contract items — use _NA to skip expected_title: Any = _NA # str | None | _NA + expected_title_source: Any = _NA # TitleSource | None | _NA has_tool_use: bool | object = _NA # True/False or _NA has_thinking: bool | object = _NA # True/False or _NA has_paste: bool | object = _NA # True/False or _NA @@ -581,6 +582,7 @@ def _beads_issue_payload() -> list[JSONDocument]: payload=_chatgpt_payload(), parse_fn=chatgpt_parse, expected_title="ChatGPT regression fixture", + expected_title_source=TitleSource.ORIGIN, # bd polylogue-4fm3: code-interpreter calls are TOOL_USE (paired with # their execution_output TOOL_RESULT via a shared tool_id), not # CODE -- CODE-typed calls were invisible to action_pairs (which @@ -715,6 +717,7 @@ def _beads_issue_payload() -> list[JSONDocument]: parse_fn=_ag_parse, dispatch_payload=markdown_export_payload(_AG_SUMMARY, _AG_MARKDOWN), expected_title="Antigravity regression fixture", + expected_title_source=TitleSource.ORIGIN, has_tool_use=False, has_thinking=False, has_paste=False, @@ -737,6 +740,7 @@ def _beads_issue_payload() -> list[JSONDocument]: payload=_hermes_payload(), parse_fn=parse_hermes, expected_title=_NA, # hermes titles session_id + expected_title_source=None, # the session id is a parser fallback has_tool_use=True, has_thinking=True, has_paste=False, @@ -759,6 +763,7 @@ def _beads_issue_payload() -> list[JSONDocument]: parse_fn=grok_parse, dispatch_payload={"conversations": [_grok_payload()]}, expected_title="Grok regression fixture", + expected_title_source=TitleSource.ORIGIN, has_tool_use=False, has_thinking=False, has_paste=False, @@ -929,7 +934,14 @@ def test_origin_contract(fixture: OriginFixture) -> None: f"[{fixture.label}] title mismatch: expected {fixture.expected_title!r}, got {session.title!r}" ) - # --- 10. Block-type assertions ------------------------------------- + # --- 10. Optional title-provenance assertion ----------------------- + if fixture.expected_title_source is not _NA: + assert session.title_source == fixture.expected_title_source, ( + f"[{fixture.label}] title_source mismatch: expected {fixture.expected_title_source!r}, " + f"got {session.title_source!r}" + ) + + # --- 11. Block-type assertions ------------------------------------- block_types = _block_types(session) if fixture.has_tool_use is not _NA: @@ -1045,6 +1057,7 @@ def test_browser_capture_snapshot_dispatches_to_chatgpt_export_origin() -> None: assert sessions[0].source_name is Provider.CHATGPT assert sessions[0].provider_session_id == "browser-snapshot-reg-1" assert origin_from_provider(sessions[0].source_name) is Origin.CHATGPT_EXPORT + assert sessions[0].title_source is TitleSource.ORIGIN # --------------------------------------------------------------------------- diff --git a/tests/unit/sources/test_browser_capture.py b/tests/unit/sources/test_browser_capture.py index 99e7edf5b6..ee38652e1b 100644 --- a/tests/unit/sources/test_browser_capture.py +++ b/tests/unit/sources/test_browser_capture.py @@ -10,7 +10,7 @@ from polylogue.browser_capture.models import BrowserCaptureEnvelope from polylogue.browser_capture.receiver import write_capture_envelope from polylogue.config import Source, get_config -from polylogue.core.enums import Provider +from polylogue.core.enums import Provider, TitleSource from polylogue.sources.dispatch import detect_provider, parse_payload from polylogue.sources.parsers.browser_capture import ( COMPACT_BROWSER_CAPTURE_INGEST_FLAG, @@ -18,6 +18,9 @@ NATIVE_BROWSER_CAPTURE_INGEST_FLAG, TEMPORARY_CHAT_INGEST_FLAG, ) +from polylogue.sources.parsers.browser_capture import ( + parse as parse_browser_capture, +) from polylogue.storage.blob_store import BlobStore from tests.infra.archive_scenarios import open_index_db @@ -78,6 +81,17 @@ def test_browser_capture_parses_session_metadata_and_deduplicates_turns() -> Non assert session.source_name is Provider.CHATGPT assert session.provider_session_id == "conv-123" assert session.title == "Work plan" + assert session.title_source is TitleSource.ORIGIN + + +def test_browser_capture_page_title_fallback_is_not_provider_title() -> None: + payload = json.loads(json.dumps(_capture_payload())) + payload["session"].pop("title") + + session = parse_browser_capture(payload, "fallback") + + assert session.title == "ChatGPT - Work plan" + assert session.title_source is None assert session.updated_at == "2026-04-24T00:00:01+00:00" assert [message.provider_message_id for message in session.messages] == ["u1", "a1"] assert len(session.attachments) == 1 diff --git a/tests/unit/storage/test_title_provenance_origins.py b/tests/unit/storage/test_title_provenance_origins.py new file mode 100644 index 0000000000..b0f90577c2 --- /dev/null +++ b/tests/unit/storage/test_title_provenance_origins.py @@ -0,0 +1,136 @@ +"""Production-route title provenance coverage for active origin parsers.""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import pytest + +from polylogue.archive.filter.filters import SessionFilter +from polylogue.archive.query.plan import SessionQueryPlan +from polylogue.core.enums import Origin, TitleSource +from polylogue.core.sources import origin_from_provider +from polylogue.sources.parsers.base import ParsedSession +from polylogue.sources.parsers.browser_capture import parse as parse_browser_capture +from polylogue.sources.parsers.hermes_state import parse_state_db +from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore +from polylogue.surfaces.payloads import session_summary_envelope_from_domain +from tests.infra.live_ingest import write_session_sync +from tests.infra.storage_records import db_setup +from tests.unit.sources.parsers.test_hermes_state import _write_state_db +from tests.unit.sources.parsers.test_origin_regression_pack import ( + ORIGIN_FIXTURES, + OriginFixture, + _browser_capture_snapshot_payload, +) + +_TITLE_CASES = ( + ("chatgpt-export", "ChatGPT regression fixture"), + ("antigravity-session", "Antigravity regression fixture"), + ("grok-export", "Grok regression fixture"), + ("browser-capture", "Browser snapshot regression fixture"), + ("hermes-state", "Outcome fixture"), +) + + +def _origin_fixture(label: str) -> OriginFixture: + return next(fixture for fixture in ORIGIN_FIXTURES if fixture.label == label) + + +def _parse_title_case(label: str, tmp_path: Path) -> ParsedSession: + if label == "hermes-state": + state_path = tmp_path / "hermes" / "state.db" + _write_state_db(state_path, tool_contents=['{"output":"ok","exit_code":0}']) + return parse_state_db(state_path)[0] + if label == "browser-capture": + return parse_browser_capture(_browser_capture_snapshot_payload(), "fallback") + fixture = _origin_fixture(label) + return cast(ParsedSession, fixture.parse_fn(fixture.payload, fixture.session_id)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("label,expected_title", _TITLE_CASES, ids=[case[0] for case in _TITLE_CASES]) +async def test_provider_title_survives_production_ingest_and_public_session_conversion( + workspace_env: dict[str, Path], + tmp_path: Path, + label: str, + expected_title: str, +) -> None: + """Parser evidence must survive storage, full-session reads, and labels. + + The production symbols exercised are the selected parser, ``write_session_sync`` + and ``write_parsed_session_to_archive`` for materialization, + ``ArchiveStore.read_summary``/``read_session``, ``SessionFilter.list`` plus + ``_session_to_session``, and ``session_summary_envelope_from_domain``. + Removing the parser's ``TitleSource.ORIGIN`` assignment makes the storage + summary title ``None`` and changes the full-session display label to a + structural fallback, so the assertions fail without changing this oracle. + """ + parsed = _parse_title_case(label, tmp_path) + archive_root = workspace_env["archive_root"] + db_path = db_setup(workspace_env) + with ArchiveStore(archive_root, initialize=True, read_only=False): + pass + + session_id = write_session_sync(db_path, parsed) + origin = origin_from_provider(parsed.source_name) + assert isinstance(origin, Origin) + + with ArchiveStore(archive_root, initialize=False, read_only=True) as archive: + stored_summary = archive.read_summary(session_id) + stored_full = archive.read_session(session_id) + + assert stored_summary.title == expected_title + assert stored_summary.title_source == TitleSource.ORIGIN.value + assert stored_summary.display_label == expected_title + assert stored_full.title == expected_title + assert stored_full.title_source == TitleSource.ORIGIN.value + + plan = SessionQueryPlan(origins=(origin.value,), limit=10) + sessions = await SessionFilter(archive_root=archive_root, query_plan=plan).list() + session = next(item for item in sessions if str(item.id) == session_id) + assert session.title == expected_title + assert session.title_source is TitleSource.ORIGIN + assert session.display_title == expected_title + + public_summary = session_summary_envelope_from_domain(session) + assert public_summary.title == expected_title + assert public_summary.title_source == TitleSource.ORIGIN.value + + +@pytest.mark.asyncio +async def test_provider_title_degrades_when_parser_provenance_is_removed( + workspace_env: dict[str, Path], + tmp_path: Path, +) -> None: + """Removing typed parser provenance must make the public title disappear.""" + parsed = _parse_title_case("grok-export", tmp_path) + expected_title = parsed.title + assert expected_title == "Grok regression fixture" + assert parsed.title_source is TitleSource.ORIGIN + mutated = parsed.model_copy(update={"title_source": None}) + + archive_root = workspace_env["archive_root"] + db_path = db_setup(workspace_env) + with ArchiveStore(archive_root, initialize=True, read_only=False): + pass + + session_id = write_session_sync(db_path, mutated) + with ArchiveStore(archive_root, initialize=False, read_only=True) as archive: + stored_summary = archive.read_summary(session_id) + + assert stored_summary.title is None + assert stored_summary.title_source is None + assert stored_summary.display_label != expected_title + + plan = SessionQueryPlan(origins=(Origin.GROK_EXPORT.value,), limit=10) + sessions = await SessionFilter(archive_root=archive_root, query_plan=plan).list() + session = next(item for item in sessions if str(item.id) == session_id) + assert session.title is None + assert session.title_source is None + assert session.display_title != expected_title + + public_summary = session_summary_envelope_from_domain(session) + assert public_summary.title != expected_title + assert public_summary.title_source is None From fe8c89666b260a0c81196a2458d74ad0554acc21 Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 8 Aug 2026 19:49:42 +0200 Subject: [PATCH 2/3] chore(beads): close title provenance contract Record polylogue-o5smo as satisfied by the parser-to-public-surface implementation, focused production-route coverage, red provenance mutation, and clean quick gate. --- .beads/issues.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index d343e89656..6394278cf8 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -10,7 +10,7 @@ {"_type":"issue","id":"polylogue-ohkfy","title":"test: make incident ledger current-set authoritative","description":"The incident ledger merged in PR #3839 validates its own checked-in graph fixture but does not prove that the current Beads forcing set equals the ledger forcing set. It also lacks typed dependency-kind validation, source resolution for fixtures and receipts, bead-linked receipt ownership, and an unconditional devtools gate. These are review findings 3726231031, 3726231040, 3726292151, 3726231046, 3726292133, 3726292138, and 3726292144.\n\nMake the existing incident coverage validator consume a structured current-Beads export or digest supplied by the verification control plane. Keep natural language out of the gate. Preserve the committed ledger as a reviewed artifact, but fail closed when a current direct forcing dependency or P0 live acceptance Bead is absent, when dependency kinds are unknown, or when catalog references do not resolve. This is implementation and verification scope only. Do not close the campaign ledger Bead until the dynamic equality proof is green.\n","design":"Extend the existing versioned JSON ledger schema with an explicit closed dependency-kind vocabulary, typed route and receipt ownership fields, and source references that resolve to committed fixtures or named live-proof receipt producers. Add a loader path that receives the current Beads forcing-set export from the devtools command rather than parsing prose or importing a stale graph fixture. The current forcing set is the transitive dependency closure relevant to 818fy, including open, in-progress, and closed implementation nodes with named residual successors. Compare it to the ledger row set and require exact equality after the declared implementation-to-successor normalization.\n\nWire one unconditional check into the reindex verification command. It must run even when no optional campaign environment is present and must fail with a structured report naming missing, extra, stale, and unresolved entries. Add red tests that delete one current forcing row, add one new P0 blocker, change a dependency kind, remove a fixture source, and detach a receipt from its owning Bead. Do not infer correctness from close-reason text.\n","acceptance_criteria":"1. Current Beads forcing-set equality is checked against the ledger on every relevant devtools verification run.\n2. Dependency kinds are a closed typed vocabulary and unknown kinds fail validation.\n3. Every fixture, check, snapshot, receipt producer, and successor reference resolves.\n4. Every live receipt is associated with its owning Bead and cannot satisfy another row by name alone.\n5. The check is unconditional for the reindex gate and emits machine-readable missing/extra/stale diagnostics.\n6. Controlled red mutations make the validator fail for one missing row, one extra blocker, one unknown dependency kind, one missing source, and one unowned receipt.\n7. Existing ledger tests pass and devtools verify --quick passes.","status":"open","priority":0,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-06T15:25:08Z","created_by":"Sinity","updated_at":"2026-08-06T15:25:18Z","labels":["area:verification","lane:reindex"],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"polylogue-27522","title":"test: close Codex 804 revision proof residuals","description":"The merged Codex 804-revision proof fixture has four unresolved correctness gaps from Codex review comments 3728404649, 3728830133, and 3728830142 on PR #3855. The fixture must preserve stable timestamps for already-existing logical messages across every revision, inject interruption before the durable paused checkpoint rather than after it, and require every raw revision to be resolved by the post-recovery authority census. The existing live-proof Bead remains open until a real production receipt exists.\n\nThis is implementation and test scope only. It must not mutate production, close the live-proof Bead, or claim that the synthetic fixture is a live receipt.\n","design":"Use the current origin/master Codex 804 scenario and existing resumable rebuild seams. Keep the wire fixture at 804 revisions and the production acquisition/parser/rebuild path. Preserve the timestamp of the two baseline messages from revision zero when the payload is recursively extended. Add a real interruption boundary before the transaction becomes durably paused, using the existing production subprocess/rebuild seam or an exact checkpoint injection seam already used by the resume tests. The test must prove that a kill before the paused checkpoint does not falsely report durable progress, that restart resumes from the durable boundary, and that a crash after a committed page remains recoverable and idempotent.\n\nReplace permissive authority assertions with an exact census: all 804 raw revisions must have an unambiguous accepted authority, no parse errors, no quarantined revisions, and a complete membership/head population. Preserve the existing assertions for terminal blob hash, selected head, candidate generation, public reads, and canonical snapshot. Add a controlled red mutation for timestamp drift, false paused-state reporting, and incomplete authority acceptance. Do not weaken production gates or add a second rebuild implementation.\n","acceptance_criteria":"1. Existing message IDs keep identical semantic timestamps across all 804 wire revisions.\n2. The interruption test kills before the durable paused/checkpoint state and proves no false progress receipt is emitted.\n3. Restart through the existing resumable rebuild route reaches an inactive candidate without replaying committed raw revisions twice.\n4. Every raw revision is resolved exactly once or has an explicitly evidenced accepted authority; no quarantined or ambiguous row remains.\n5. Existing terminal blob, candidate, canonical snapshot, public summary/tree/search, and selected-head assertions still pass.\n6. Red mutation tests fail when timestamp preservation, interruption boundary, or complete authority census is removed.\n7. Focused Codex scenario and existing resume correctness tests pass, plus devtools verify --quick.\n8. PR body and structured carrier state implementation-complete, live-proof-pending, with the live successor named.","notes":"Codex closed-PR audit 2026-08-06: PR #3855 findings 3727971576, 3728404631, 3728404637, 3728404643, 3728404646, 3728830133, and 3728830142 remain the exact implementation residual set. Resource scope, sibling receipt copies, symlink double-counting, phase attribution, guarded promotion, pre-checkpoint interruption, and complete authority census must remain explicit.","status":"open","priority":0,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-06T15:25:05Z","created_by":"Sinity","updated_at":"2026-08-06T19:24:18Z","labels":["area:verification","lane:reindex"],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"polylogue-tiozw","title":"fix: discard partial hook match stages before liveness checks","description":"Residual P1 from Codex review of merged PR #3847. A failed hook match-stage build can leave both temporary tables behind, and the next candidate can mistake the partial stage for a complete ready stage. That can make blob liveness verification accept a false negative and delete evidence after an interrupted query.\n","design":"Make match-stage construction transactional and readiness-bearing. A stage is visible to candidate evaluation only after every table, population statement, and integrity check succeeds. On any exception, drop all stage tables created by that attempt and clear the readiness marker. A later candidate must rebuild rather than reuse partial state. Add crash and exception injection tests for failure after each stage table creation and after population begins, then assert no candidate can report a clean dead-blob result from partial state.\n","acceptance_criteria":"1. Failed match-stage construction removes all created tables and readiness state. 2. Later candidates never reuse partial stages. 3. Successful reuse requires complete integrity proof. 4. Injected failures make blob-liveness verification fail closed. 5. Focused hook/blob-liveness tests and devtools verify --quick pass; no live mutation.","status":"open","priority":0,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-06T15:14:07Z","created_by":"Sinity","updated_at":"2026-08-06T15:14:07Z","labels":["area:maintenance","area:storage","lane:reindex"],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"polylogue-o5smo","title":"fix: preserve parser title provenance across public surfaces","description":"Residual P1 from Codex review of merged PR #3846. The title-presence gate drops genuine titles from Grok, Antigravity, browser capture, and Hermes parsers because those producers leave ParsedSession.title_source unset. The public summary then falls back to generated labels even though authored provider title evidence exists.\n","design":"Trace all active parser title producers and assign the existing title-provenance vocabulary at the parser boundary, or make the public conversion recognize a typed parser-title source. Do not broaden acceptance to arbitrary nonempty text. Cover Grok, Antigravity, browser capture, Hermes, and at least one already-supported origin in a shared production-ingest fixture. Verify the title ladder, full-session conversion, and display-label path consume the same typed provenance. Add a red mutation that removes or relabels the parser provenance and makes the public title disappear or degrade visibly.\n","acceptance_criteria":"1. Genuine titles from Grok, Antigravity, browser capture, and Hermes survive full-session conversion and display-label generation. 2. Synthetic and heuristic titles remain governed by existing provenance policy. 3. Tests exercise parser, storage, and public conversion with a red provenance mutation. 4. Focused tests and devtools verify --quick pass; no live mutation.","status":"open","priority":0,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-06T15:14:03Z","created_by":"Sinity","updated_at":"2026-08-06T15:14:03Z","labels":["area:ingest","area:query","lane:reindex"],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"polylogue-o5smo","title":"fix: preserve parser title provenance across public surfaces","description":"Residual P1 from Codex review of merged PR #3846. The title-presence gate drops genuine titles from Grok, Antigravity, browser capture, and Hermes parsers because those producers leave ParsedSession.title_source unset. The public summary then falls back to generated labels even though authored provider title evidence exists.\n","design":"Trace all active parser title producers and assign the existing title-provenance vocabulary at the parser boundary, or make the public conversion recognize a typed parser-title source. Do not broaden acceptance to arbitrary nonempty text. Cover Grok, Antigravity, browser capture, Hermes, and at least one already-supported origin in a shared production-ingest fixture. Verify the title ladder, full-session conversion, and display-label path consume the same typed provenance. Add a red mutation that removes or relabels the parser provenance and makes the public title disappear or degrade visibly.\n","acceptance_criteria":"1. Genuine titles from Grok, Antigravity, browser capture, and Hermes survive full-session conversion and display-label generation. 2. Synthetic and heuristic titles remain governed by existing provenance policy. 3. Tests exercise parser, storage, and public conversion with a red provenance mutation. 4. Focused tests and devtools verify --quick pass; no live mutation.","status":"closed","priority":0,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-06T15:14:03Z","created_by":"Sinity","updated_at":"2026-08-08T17:47:27Z","closed_at":"2026-08-08T17:47:27Z","close_reason":"Satisfied by the title-provenance production route: genuine Grok, Antigravity, browser capture, and Hermes titles now carry typed origin provenance through parser, storage, SessionFilter, public summary, and display-label conversion; heuristic fallbacks remain untrusted; the provenance-removal mutation degrades the public title; 117 focused tests and all 24 quick checks pass; no live mutation.","labels":["area:ingest","area:query","lane:reindex"],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"polylogue-ehzfn","title":"test: make corpus programs exercise production mutations","description":"Residual implementation scope from Codex review of merged PR #3843. The typed CorpusProgram exists, but several operations do not reach the production seam they claim to exercise, generated programs are often invalid before execution, hook writes use provider tokens where storage requires Origin, and low-level promotion bypasses production ownership and transaction finalization.\n\nThis work is a prerequisite for rrxe4 proof quality. It must not become a second archive engine and it must not claim a candidate or live receipt.\n","design":"In tests/infra/corpus_program.py and its focused tests, make transformed artifacts materialize through the existing acquisition seam with their current payload, attachment metadata and bytes. EmitHook must use the canonical provider-to-origin mapping and persist the complete normalized hook envelope consumed by production readers. Build corpus_program_strategy from an evolving acquired-artifact state so the default generated examples are executable; invalid-operation outcomes must be explicit if retained. Make corpus_program_schedule_strategy actually generate a permutation. Route Rebuild and Promote through the owned production promotion boundary, preserving terminal transaction state and refusing source drift or ownership loss. Add anti-vacuity mutations that prove append, replace, attachment, hook, order, and promotion changes affect the real archive path.\n","acceptance_criteria":"1. Transformed artifacts reacquire through the production seam with changed payload or attachment evidence. 2. Hook operations persist canonical Origin and complete normalized payload. 3. Generated programs are executable and schedules include permutations. 4. Promotion preserves owned-boundary checks and terminal transaction state. 5. Anti-vacuity mutations fail before the repair and pass after it. 6. Focused corpus-program tests and devtools verify --quick pass; no live mutation.","status":"closed","priority":0,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-06T15:13:59Z","created_by":"Sinity","updated_at":"2026-08-08T14:14:18Z","closed_at":"2026-08-08T14:14:18Z","close_reason":"Satisfied by the corpus production-route implementation and end-to-end ownership proofs. Transformed mutations reacquire current bytes; attachments retain reopenable blob bytes; hook events persist canonical Origin and normalized envelopes; generated programs are executable with real schedule permutations; rebuild and promotion use the owned durable transaction; refreshed source drift marks the old candidate stale without moving the active pointer. Verification: tests/infra/test_corpus_program.py 13 passed; devtools verify --quick 24 steps passed. The incident-scale Codex 804 consumer reproduced its exact origin/master baseline timeout under polylogue-93xe and does not execute this lane's changed mutation, attachment, hook, or promotion operations.","labels":["area:verification","lane:reindex"],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"polylogue-q8tpq","title":"maintenance: install typed live-proof receipt protocol","description":"Implement one static, typed live-proof receipt protocol for the reindex campaign. It must collect read-only, candidate, and already-produced apply receipts without becoming a task scheduler or mutation surface. Every receipt binds the proof and Bead IDs, exact code SHA, archive identity, source snapshot, schema versions, candidate identity when applicable, parser and lowering fingerprints, registry version, structured result, typed residues, input receipt digests, and private-path digests. This is the shared evidence protocol required before live-proof children can contribute to the terminal reindex proof.\n","design":"Add a versioned LiveProofSpec registry and receipt collector. Register the fixed proof modes read_only, candidate, and existing_apply_receipt. Expose polylogue ops maintenance live-proof with a fixed proof ID and receipt input/output contract. Read-only and candidate producers may execute only registered callables; existing-apply mode validates an immutable receipt. The command must never apply a mutation, stop or start the daemon, migrate a tier, promote a generation, accept arbitrary commands, or infer proof from Beads status. Private paths are represented by SHA-256 digest plus basename. Wire the collector into the live-operation aggregate and the candidate/final proof consumers. Reuse the existing archive-verification registry and canonical fingerprint helpers.\n","acceptance_criteria":"1. The three fixed proof modes and typed residue vocabulary are represented by one registry.\n2. The maintenance command accepts only registered proof IDs and the mode-specific input shape.\n3. Receipts are immutable, self-hashed, and bind code, archive, source snapshot, schema, semantic fingerprints, result, residues, and input receipts.\n4. Candidate receipts bind the exact inactive candidate generation; existing-apply receipts bind the validated input receipt.\n5. Private paths never appear in durable receipt payloads except as digest plus basename.\n6. The command has no mutation, daemon lifecycle, migration, promotion, or arbitrary-command path.\n7. Missing, stale, or malformed bindings fail closed, and a controlled mutation of any required binding makes validation fail.\n8. Focused tests cover registry completeness, receipt determinism, mode isolation, binding failures, and the real CLI dispatch path.\n9. The protocol is a prerequisite of polylogue-live-operation-receipts and remains open until its implementation and focused verification merge.","notes":"Compiled packet intake 2026-08-06. Source packet tar SHA-256: cae45456e8f25c491085c2035afc8fbf36545e4ac59c55bd53c114e6d4179189. Execution-spec SHA-256: 64fa47bd7d42e0d4e81b3e77db2216a88300dd81b08141dd6e79a54319607303. The packet graph basis is 685f2ca8, so current phase names and dependencies must be checked against current Beads before dispatch.","status":"closed","priority":0,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-06T13:52:37Z","created_by":"Sinity","updated_at":"2026-08-06T16:53:35Z","closed_at":"2026-08-06T16:53:35Z","close_reason":"Closed as a duplicate of canonical open protocol Bead polylogue-x97cf; implementation ownership and downstream proof edges remain on x97cf.","labels":["area:maintenance","lane:reindex"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"polylogue-x97cf","title":"maintenance: install typed live-proof receipt protocol","description":"Implement one static, typed live-proof receipt protocol for the reindex campaign. It must collect read-only, candidate, and already-produced apply receipts without becoming a task scheduler or mutation surface. Every receipt binds the proof and Bead IDs, exact code SHA, archive identity, source snapshot, schema versions, candidate identity when applicable, parser and lowering fingerprints, registry version, structured result, typed residues, input receipt digests, and private-path digests. This is the shared evidence protocol required before live-proof children can contribute to the terminal reindex proof.\n","design":"Add a versioned LiveProofSpec registry and receipt collector. Register the fixed proof modes read_only, candidate, and existing_apply_receipt. Expose polylogue ops maintenance live-proof with a fixed proof ID and receipt input/output contract. Read-only and candidate producers may execute only registered callables; existing-apply mode validates an immutable receipt. The command must never apply a mutation, stop or start the daemon, migrate a tier, promote a generation, accept arbitrary commands, or infer proof from Beads status. Private paths are represented by SHA-256 digest plus basename. Wire the collector into the live-operation aggregate and the candidate/final proof consumers. Reuse the existing archive-verification registry and canonical fingerprint helpers.\n","acceptance_criteria":"1. The three fixed proof modes and typed residue vocabulary are represented by one registry.\n2. The maintenance command accepts only registered proof IDs and the mode-specific input shape.\n3. Receipts are immutable, self-hashed, and bind code, archive, source snapshot, schema, semantic fingerprints, result, residues, and input receipts.\n4. Candidate receipts bind the exact inactive candidate generation; existing-apply receipts bind the validated input receipt.\n5. Private paths never appear in durable receipt payloads except as digest plus basename.\n6. The command has no mutation, daemon lifecycle, migration, promotion, or arbitrary-command path.\n7. Missing, stale, or malformed bindings fail closed, and a controlled mutation of any required binding makes validation fail.\n8. Focused tests cover registry completeness, receipt determinism, mode isolation, binding failures, and the real CLI dispatch path.\n9. The protocol is a prerequisite of polylogue-live-operation-receipts and remains open until its implementation and focused verification merge.","notes":"Compiled packet intake 2026-08-06. Source packet tar SHA-256: cae45456e8f25c491085c2035afc8fbf36545e4ac59c55bd53c114e6d4179189. Execution-spec SHA-256: 64fa47bd7d42e0d4e81b3e77db2216a88300dd81b08141dd6e79a54319607303. The packet graph basis is 685f2ca8, so current phase names and dependencies must be checked against current Beads before dispatch.\nGraph correction from Codex review on PR #3861 (comment 5205727476): closed duplicate polylogue-q8tpq no longer claims to supersede this canonical Bead. The supersedes relationship is now x97cf -> q8tpq; x97cf remains the open implementation owner consumed by live-operation and candidate proof.","status":"open","priority":0,"issue_type":"task","owner":"ezo.dev@gmail.com","created_at":"2026-08-06T13:50:01Z","created_by":"Sinity","updated_at":"2026-08-06T15:55:02Z","labels":["area:maintenance","lane:reindex"],"dependencies":[{"issue_id":"polylogue-x97cf","depends_on_id":"polylogue-q8tpq","type":"supersedes","created_at":"2026-08-06T17:54:48Z","created_by":"Sinity","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} From cde7fef6e395a263ec112a15ee42d16bfe08f37b Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 8 Aug 2026 20:19:05 +0200 Subject: [PATCH 3/3] fix(browser-capture): bind title provenance to producer evidence Problem Browser envelopes carried fallback page titles and session ids in the same field as provider-authored titles. Native ChatGPT parsing also merged the envelope capture timestamp as provider update time. Existing derived rows could not be repaired without replay. What changed Add a typed title-source field to live and backfill producers, trust only provider evidence in the parser, and keep native ChatGPT timestamp authority inside its native payload. Declare the semantic parser change as index v67 and refresh generated demo evidence. Pin the v64-to-v65 executor fixture to its stated target so later semantic versions do not invalidate it. Verification - 157 focused Python tests passed - 65 browser-extension tests passed - browser-extension ESLint passed - devtools lab policy schema-versioning passed - devtools verify --quick passed all 24 steps Ref polylogue-o5smo. Co-Authored-By: Codex --- browser-extension/src/backfill/providers.js | 1 + browser-extension/src/common.js | 5 +- browser-extension/tests/backfill.test.js | 1 + .../tests/common_envelope_boundary.test.js | 34 ++++++++++ .../01-claim-versus-receipt.txt | 2 +- docs/examples/demo-tour/transcript.txt | 2 +- polylogue/browser_capture/models.py | 2 + polylogue/sources/parsers/browser_capture.py | 41 ++++++++++-- .../storage/sqlite/archive_tiers/index.py | 6 +- polylogue/storage/sqlite/lifecycle.py | 8 +++ .../parsers/test_origin_regression_pack.py | 1 + tests/unit/sources/test_browser_capture.py | 67 ++++++++++++++++++- .../test_index_fast_forward_executor.py | 14 +++- 13 files changed, 170 insertions(+), 14 deletions(-) diff --git a/browser-extension/src/backfill/providers.js b/browser-extension/src/backfill/providers.js index 5866b82665..81ed779c8a 100644 --- a/browser-extension/src/backfill/providers.js +++ b/browser-extension/src/backfill/providers.js @@ -153,6 +153,7 @@ function envelope({ provider, nativeId, title, createdAt, updatedAt, turns, rawP provider_session_id: nativeId, session_kind: sessionKind === "temporary" ? "temporary" : "standard", title: title || nativeId, + title_source: title ? "provider" : "session-id", created_at: createdAt, updated_at: updatedAt, provider_meta: { capture_fidelity: captureFidelity, backfill: attribution }, diff --git a/browser-extension/src/common.js b/browser-extension/src/common.js index 6555bc5c36..23553205ae 100644 --- a/browser-extension/src/common.js +++ b/browser-extension/src/common.js @@ -108,6 +108,8 @@ ? "temporary" : "standard"; const now = new Date().toISOString(); + const sessionTitle = title || document.title || stableProviderSessionId; + const sessionTitleSource = title ? "provider" : document.title ? "page" : "session-id"; const envelope = { polylogue_capture_kind: CAPTURE_KIND, schema_version: SCHEMA_VERSION, @@ -126,7 +128,8 @@ provider, provider_session_id: stableProviderSessionId, session_kind: stableSessionKind, - title: title || document.title || stableProviderSessionId, + title: sessionTitle, + title_source: sessionTitleSource, created_at: createdAt, updated_at: updatedAt || now, model, diff --git a/browser-extension/tests/backfill.test.js b/browser-extension/tests/backfill.test.js index 2e72692e34..6642e7ddfd 100644 --- a/browser-extension/tests/backfill.test.js +++ b/browser-extension/tests/backfill.test.js @@ -107,6 +107,7 @@ describe("background backfill coordinator", () => { expect(status.progress.complete).toBe(2); expect(h.adapter.fetchCalls).toEqual(["one", "two"]); expect(h.receiver).toHaveBeenCalledTimes(2); + expect(h.receiver.mock.calls[0][0].session.title_source).toBe("provider"); }); it("recovers the durable job, queue, revision, and ACK ledgers from real IndexedDB after a worker restart", async () => { diff --git a/browser-extension/tests/common_envelope_boundary.test.js b/browser-extension/tests/common_envelope_boundary.test.js index a21681d884..d10fc6be3a 100644 --- a/browser-extension/tests/common_envelope_boundary.test.js +++ b/browser-extension/tests/common_envelope_boundary.test.js @@ -128,6 +128,40 @@ function maximalTurn() { } describe("common.js buildEnvelope boundary contract (real source, not a copy)", () => { + it("types provider, page, and session-id title evidence", () => { + const providerDom = installCommon(); + const providerEnvelope = providerDom.window.polylogueCapture.buildEnvelope({ + provider: "chatgpt", + adapterName: "chatgpt-native-v1", + turns: [maximalTurn()], + providerSessionId: "conversation-1", + title: "Provider conversation title", + }); + expect(providerEnvelope.session.title).toBe("Provider conversation title"); + expect(providerEnvelope.session.title_source).toBe("provider"); + + const pageDom = installCommon(); + const pageEnvelope = pageDom.window.polylogueCapture.buildEnvelope({ + provider: "chatgpt", + adapterName: "chatgpt-dom-v1", + turns: [maximalTurn()], + providerSessionId: "conversation-1", + }); + expect(pageEnvelope.session.title).toBe("Boundary contract fixture"); + expect(pageEnvelope.session.title_source).toBe("page"); + + const sessionIdDom = installCommon(); + sessionIdDom.window.document.title = ""; + const sessionIdEnvelope = sessionIdDom.window.polylogueCapture.buildEnvelope({ + provider: "chatgpt", + adapterName: "chatgpt-dom-v1", + turns: [maximalTurn()], + providerSessionId: "conversation-1", + }); + expect(sessionIdEnvelope.session.title).toBe("conversation-1"); + expect(sessionIdEnvelope.session.title_source).toBe("session-id"); + }); + it("carries every BrowserCaptureTurn field from a maximal turn into the envelope", () => { const dom = installCommon(); const envelope = dom.window.polylogueCapture.buildEnvelope({ diff --git a/docs/examples/demo-tour/command-output/01-claim-versus-receipt.txt b/docs/examples/demo-tour/command-output/01-claim-versus-receipt.txt index 613b302b53..f2ad4a3156 100644 --- a/docs/examples/demo-tour/command-output/01-claim-versus-receipt.txt +++ b/docs/examples/demo-tour/command-output/01-claim-versus-receipt.txt @@ -29,7 +29,7 @@ source material: blob_sha256: 9fd0dbdb080058070935924534a903cc63a8dcba571f6b2734f92a96576b59d7 completion-claim experiment: - sample manifest: a7a39bdcced3d950886d5b555d2ceec7b5f776d961fa726a76946c857f863d13 + sample manifest: 014380e82576a22360db0b18c25a984b62fdb057e535aadd1c9b448cf40f2466 denominator: 2 unsupported by structural evidence: 0 (0.0%) neutral prior outcome: 0 (0.0%) diff --git a/docs/examples/demo-tour/transcript.txt b/docs/examples/demo-tour/transcript.txt index 258c8be69d..b2c248db62 100644 --- a/docs/examples/demo-tour/transcript.txt +++ b/docs/examples/demo-tour/transcript.txt @@ -38,7 +38,7 @@ source material: blob_sha256: 9fd0dbdb080058070935924534a903cc63a8dcba571f6b2734f92a96576b59d7 completion-claim experiment: - sample manifest: a7a39bdcced3d950886d5b555d2ceec7b5f776d961fa726a76946c857f863d13 + sample manifest: 014380e82576a22360db0b18c25a984b62fdb057e535aadd1c9b448cf40f2466 denominator: 2 unsupported by structural evidence: 0 (0.0%) neutral prior outcome: 0 (0.0%) diff --git a/polylogue/browser_capture/models.py b/polylogue/browser_capture/models.py index e5266f82f3..0abd884d70 100644 --- a/polylogue/browser_capture/models.py +++ b/polylogue/browser_capture/models.py @@ -19,6 +19,7 @@ BROWSER_CAPTURE_EXTENSION_ORIGIN_WILDCARD: Literal["chrome-extension://*"] = "chrome-extension://*" BrowserCaptureArchiveLifecycle = Literal["missing", "spooled_only", "ingest_pending", "archived", "stale", "failed"] BrowserCaptureSessionKind = Literal["standard", "temporary"] +BrowserCaptureTitleSource = Literal["provider", "page", "session-id"] class BrowserCaptureAttachment(BaseModel): @@ -168,6 +169,7 @@ class BrowserCaptureSession(BaseModel): provider_session_id: str session_kind: BrowserCaptureSessionKind = "standard" title: str | None = None + title_source: BrowserCaptureTitleSource | None = None created_at: str | None = None updated_at: str | None = None model: str | None = None diff --git a/polylogue/sources/parsers/browser_capture.py b/polylogue/sources/parsers/browser_capture.py index bad3d3e19f..ec7b52e5f2 100644 --- a/polylogue/sources/parsers/browser_capture.py +++ b/polylogue/sources/parsers/browser_capture.py @@ -329,6 +329,34 @@ def _merge_envelope_attachments(parsed: ParsedSession, envelope: BrowserCaptureE return parsed.model_copy(update={"attachments": list(merged.values())}) +def _trusted_envelope_title(envelope: BrowserCaptureEnvelope) -> str | None: + """Return only a provider-authored browser title. + + Current producers declare the source explicitly. Older v1 envelopes are + accepted conservatively only when their title differs from both fallback + values the extension is known to synthesize. + """ + title = envelope.session.title + if not title: + return None + if envelope.session.title_source == "provider": + return title + if envelope.session.title_source in {"page", "session-id"}: + return None + if title in {envelope.provenance.page_title, envelope.session.provider_session_id}: + return None + return title + + +def _merge_envelope_title(parsed: ParsedSession, envelope: BrowserCaptureEnvelope) -> ParsedSession: + """Fill only a missing native title from trusted envelope evidence.""" + trusted_title = _trusted_envelope_title(envelope) + fallback_titles = {None, "", parsed.provider_session_id, envelope.session.provider_session_id} + if parsed.title not in fallback_titles or trusted_title is None: + return parsed + return parsed.model_copy(update={"title": trusted_title, "title_source": TitleSource.ORIGIN}) + + def _merge_envelope_native_metadata(parsed: ParsedSession, envelope: BrowserCaptureEnvelope) -> ParsedSession: """Use browser-envelope fields only when the native payload omitted them. @@ -338,11 +366,8 @@ def _merge_envelope_native_metadata(parsed: ParsedSession, envelope: BrowserCapt title/model/timestamp fields from the embedded payload. """ + parsed = _merge_envelope_title(parsed, envelope) updates: dict[str, object] = {} - fallback_titles = {None, "", parsed.provider_session_id, envelope.session.provider_session_id} - if parsed.title in fallback_titles and envelope.session.title: - updates["title"] = envelope.session.title - updates["title_source"] = TitleSource.ORIGIN if parsed.created_at is None and envelope.session.created_at is not None: updates["created_at"] = envelope.session.created_at if parsed.updated_at is None and envelope.session.updated_at is not None: @@ -462,11 +487,12 @@ def _parse_claude_fallback_envelope( fidelity_flag = ( COMPACT_BROWSER_CAPTURE_INGEST_FLAG if _is_compact_native_capture(envelope) else DOM_FALLBACK_INGEST_FLAG ) + trusted_title = _trusted_envelope_title(envelope) return ParsedSession( source_name=Provider.CLAUDE_AI, provider_session_id=provider_session_id, title=envelope.session.title or envelope.provenance.page_title or provider_session_id, - title_source=TitleSource.ORIGIN if envelope.session.title else None, + title_source=TitleSource.ORIGIN if trusted_title is not None else None, session_kind=_session_kind_for_browser_capture(envelope, provider_session_id), created_at=created_at, updated_at=updated_at, @@ -643,7 +669,7 @@ def parse(payload: object, fallback_id: str) -> ParsedSession: return _merge_envelope_session_events( _apply_browser_capture_session_kind( _merge_envelope_attachments( - _merge_envelope_native_metadata(parse_chatgpt(raw_provider_payload, provider_session_id), envelope), + _merge_envelope_title(parse_chatgpt(raw_provider_payload, provider_session_id), envelope), envelope, ), envelope, @@ -730,11 +756,12 @@ def parse(payload: object, fallback_id: str) -> ParsedSession: ) for message in messages ] + trusted_title = _trusted_envelope_title(envelope) return ParsedSession( source_name=provider, provider_session_id=provider_session_id, title=envelope.session.title or envelope.provenance.page_title or provider_session_id, - title_source=TitleSource.ORIGIN if envelope.session.title else None, + title_source=TitleSource.ORIGIN if trusted_title is not None else None, session_kind=_session_kind_for_browser_capture(envelope, provider_session_id), created_at=envelope.session.created_at, updated_at=envelope.session.updated_at, diff --git a/polylogue/storage/sqlite/archive_tiers/index.py b/polylogue/storage/sqlite/archive_tiers/index.py index 7457214f8d..0a11036f03 100644 --- a/polylogue/storage/sqlite/archive_tiers/index.py +++ b/polylogue/storage/sqlite/archive_tiers/index.py @@ -444,7 +444,11 @@ # positional coordinates in the generated message_id expression. Existing # derived rows remain readable as opaque legacy ids, but new materialization # must replay raw sessions to regenerate message/block/reference identities. -INDEX_SCHEMA_VERSION = 66 +# polylogue-o5smo: v67 assigns typed origin provenance to provider-authored +# Grok, Antigravity, browser-capture, and Hermes titles. Existing parsed rows +# require raw replay because an in-place schema operation cannot recover title +# authorship from the stored untyped title text. +INDEX_SCHEMA_VERSION = 67 # polylogue-v6i3: shared WHEN-clause fragment gating the blocks_command_trigram # trigger BODIES on the same dedicated bulk-build guard row messages_fts's diff --git a/polylogue/storage/sqlite/lifecycle.py b/polylogue/storage/sqlite/lifecycle.py index eb3ee6ede6..daebeda2da 100644 --- a/polylogue/storage/sqlite/lifecycle.py +++ b/polylogue/storage/sqlite/lifecycle.py @@ -894,6 +894,14 @@ class IndexDeltaDeclarationReport(TypedDict): # by raw replay under the new expression. classes=(DerivedDeltaClass.SEMANTIC_REPARSE,), ), + IndexDeltaDeclaration( + version=67, + # Parser-authored title provenance changes stored session semantics. + # Existing untyped title text cannot distinguish provider evidence + # from page-title and native-id fallbacks, so only raw replay can + # produce trustworthy title_source values. + classes=(DerivedDeltaClass.SEMANTIC_REPARSE,), + ), ) diff --git a/tests/unit/sources/parsers/test_origin_regression_pack.py b/tests/unit/sources/parsers/test_origin_regression_pack.py index c9088d109f..77be883f9f 100644 --- a/tests/unit/sources/parsers/test_origin_regression_pack.py +++ b/tests/unit/sources/parsers/test_origin_regression_pack.py @@ -819,6 +819,7 @@ def _browser_capture_snapshot_payload() -> JSONDocument: "provider": "chatgpt", "provider_session_id": "browser-snapshot-reg-1", "title": "Browser snapshot regression fixture", + "title_source": "provider", "turns": [ {"provider_turn_id": "u1", "role": "user", "text": "Capture this page.", "ordinal": 0}, {"provider_turn_id": "a1", "role": "assistant", "text": "Captured.", "ordinal": 1}, diff --git a/tests/unit/sources/test_browser_capture.py b/tests/unit/sources/test_browser_capture.py index ee38652e1b..a6b8d16da0 100644 --- a/tests/unit/sources/test_browser_capture.py +++ b/tests/unit/sources/test_browser_capture.py @@ -41,6 +41,7 @@ def _capture_payload() -> dict[str, object]: "provider": "chatgpt", "provider_session_id": "conv-123", "title": "Work plan", + "title_source": "provider", "updated_at": "2026-04-24T00:00:01+00:00", "model": "gpt-5.4", "turns": [ @@ -86,7 +87,8 @@ def test_browser_capture_parses_session_metadata_and_deduplicates_turns() -> Non def test_browser_capture_page_title_fallback_is_not_provider_title() -> None: payload = json.loads(json.dumps(_capture_payload())) - payload["session"].pop("title") + payload["session"]["title"] = "ChatGPT - Work plan" + payload["session"]["title_source"] = "page" session = parse_browser_capture(payload, "fallback") @@ -101,6 +103,39 @@ def test_browser_capture_page_title_fallback_is_not_provider_title() -> None: assert NATIVE_BROWSER_CAPTURE_INGEST_FLAG not in session.ingest_flags +def test_browser_capture_session_id_fallback_is_not_provider_title() -> None: + payload = json.loads(json.dumps(_capture_payload())) + payload["session"]["title"] = "conv-123" + payload["session"]["title_source"] = "session-id" + + session = parse_browser_capture(payload, "fallback") + + assert session.title == "conv-123" + assert session.title_source is None + + +@pytest.mark.parametrize("fallback_title", ["ChatGPT - Work plan", "conv-123"]) +def test_legacy_browser_capture_fallback_title_is_not_provider_title(fallback_title: str) -> None: + payload = json.loads(json.dumps(_capture_payload())) + payload["session"]["title"] = fallback_title + payload["session"].pop("title_source") + + session = parse_browser_capture(payload, "fallback") + + assert session.title == fallback_title + assert session.title_source is None + + +def test_legacy_browser_capture_distinct_title_retains_provider_provenance() -> None: + payload = json.loads(json.dumps(_capture_payload())) + payload["session"].pop("title_source") + + session = parse_browser_capture(payload, "fallback") + + assert session.title == "Work plan" + assert session.title_source is TitleSource.ORIGIN + + def test_browser_capture_does_not_launder_capture_time_as_provider_update() -> None: payload = _capture_payload() session_payload = payload["session"] @@ -112,6 +147,36 @@ def test_browser_capture_does_not_launder_capture_time_as_provider_update() -> N assert parsed[0].updated_at is None +def test_native_chatgpt_title_merge_does_not_launder_capture_time() -> None: + payload = _capture_payload() + session_payload = payload["session"] + assert isinstance(session_payload, dict) + session_payload["updated_at"] = "2026-04-24T00:00:01+00:00" + payload["raw_provider_payload"] = { + "id": "native-conv", + "current_node": "assistant-node", + "mapping": { + "assistant-node": { + "id": "assistant-node", + "parent": None, + "children": [], + "message": { + "id": "native-a1", + "author": {"role": "assistant"}, + "content": {"content_type": "text", "parts": ["Native answer"]}, + "metadata": {}, + }, + } + }, + } + + session = parse_payload(Provider.CHATGPT, payload, "fallback")[0] + + assert session.title == "Work plan" + assert session.title_source is TitleSource.ORIGIN + assert session.updated_at is None + + def test_browser_capture_embedded_attachment_payloads_become_inline_bytes() -> None: payload = _capture_payload() session_payload = payload["session"] diff --git a/tests/unit/storage/test_index_fast_forward_executor.py b/tests/unit/storage/test_index_fast_forward_executor.py index 60dd846cf4..13ec266a0c 100644 --- a/tests/unit/storage/test_index_fast_forward_executor.py +++ b/tests/unit/storage/test_index_fast_forward_executor.py @@ -221,9 +221,16 @@ def test_sql_fast_forwardable_index_db_reopen_is_idempotent(tmp_path: Path, monk conn.close() -def test_v64_to_v65_fast_forward_replaces_actions_view_and_exposes_result_state(tmp_path: Path) -> None: +def test_v64_to_v65_fast_forward_replaces_actions_view_and_exposes_result_state( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: """The production archive-open path upgrades v64 action queries in place.""" + from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER + + monkeypatch.setitem(ARCHIVE_VERSION_BY_TIER, ArchiveTier.INDEX, 65) + path = tmp_path / "index.db" conn = sqlite3.connect(path) conn.row_factory = sqlite3.Row @@ -270,6 +277,9 @@ def test_v64_to_v65_fast_forward_replaces_actions_view_and_exposes_result_state( """, (message_id, session_id, 2, "tool_result", "unknown outcome", "v65-matched"), ) + matched_result_block_id = conn.execute( + "SELECT block_id FROM blocks WHERE tool_id = 'v65-matched' AND block_type = 'tool_result'" + ).fetchone()["block_id"] conn.execute("DROP VIEW actions") conn.execute( """ @@ -305,7 +315,7 @@ def test_v64_to_v65_fast_forward_replaces_actions_view_and_exposes_result_state( {"tool_command": "absent", "tool_result_block_id": None, "result_state": "no_result"}, { "tool_command": "matched", - "tool_result_block_id": "codex-session:v65-action-session:v65-action-message:2", + "tool_result_block_id": matched_result_block_id, "result_state": "outcome_unknown", }, ]