From e58f39fe07dfbb897c3ee90bc0d9622ac6eef650 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 4 Aug 2026 10:30:49 +0200 Subject: [PATCH 1/6] docs: record ChatGPT lifecycle-anchor evidence Problem: the polylogue-uqwd blocker required a fresh production-route check against the current quarantined ChatGPT population before any comparison-layer exception could be considered. What changed: add the read-only classifier evidence packet, index it in the generated docs surface, and add a moved-anchor red twin proving different lifecycle content remains a conflict. Compatibility/migration: no production behavior, archive database, Beads state, daemon state, or services changed. The packet records the current source/index identities, cohort coverage, historical cohort outcomes, and residual uncertainty. --- devtools/docs_surface.py | 6 + docs/README.md | 1 + ...polylogue-uqwd-chatgpt-lifecycle-anchor.md | 121 ++++++++++++++++++ docs/audits/README.md | 1 + .../test_session_revision_membership.py | 45 +++++++ 5 files changed, 174 insertions(+) create mode 100644 docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md diff --git a/devtools/docs_surface.py b/devtools/docs_surface.py index 03075b8c19..409b594a98 100644 --- a/devtools/docs_surface.py +++ b/devtools/docs_surface.py @@ -456,6 +456,12 @@ def _entry(title: str, path: str, description: str, tier: DocsTier) -> DocsEntry "Read-only raw-failure census before lifecycle evidence deployment.", "archive", ), + _entry( + "ChatGPT Lifecycle-Anchor Evidence Packet", + "audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md", + "Current-corpus evidence for ChatGPT generation lifecycle-anchor drift.", + "archive", + ), _entry("Audit Record Index", "audits/README.md", "Index of dated investigation records.", "archive"), _entry( "1498 Cascade Retrospective", diff --git a/docs/README.md b/docs/README.md index 4acd06a3ab..f1d02b28ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -138,6 +138,7 @@ Start with **Guides** for a task, **Reference** for a surface contract, and **Ar | [Reindex Forcing-Class Audit](audits/2026-08-04-reindex-forcing-class-audit.md) | Forcing-class and reindex-gate evidence audit. | | [Blob-Reference Liveness Closure Audit](audits/2026-08-04-blob-ref-liveness-closure.md) | I3 live evidence, source-tier reconciliation safeguards, and the direct-reindex gate. | | [Raw-Failure Preflight](audits/2026-08-04-raw-failure-preflight.md) | Read-only raw-failure census before lifecycle evidence deployment. | +| [ChatGPT Lifecycle-Anchor Evidence Packet](audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md) | Current-corpus evidence for ChatGPT generation lifecycle-anchor drift. | | [Audit Record Index](audits/README.md) | Index of dated investigation records. | | [1498 Cascade Retrospective](retro/2026-05-24-1498-cascade.md) | Historical cascade incident retrospective. | | [Retrospective Index](retro/README.md) | Index of historical incident retrospectives. | diff --git a/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md new file mode 100644 index 0000000000..1927e363e2 --- /dev/null +++ b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md @@ -0,0 +1,121 @@ +# polylogue-uqwd evidence packet: ChatGPT lifecycle-anchor drift + +Date: 2026-08-04. Worktree: `feature/investigate/chatgpt-lifecycle-anchor` at `f7b721ab38538c82021d417faea363ba152c8758`. Scope: resolve whether the current quarantined ChatGPT population still reproduces the `generation_lifecycle` moved-anchor conflict described by polylogue-uqwd, without changing Beads, source.db, index.db, the blob store, backups, daemon state, or services. + +## Verdict + +The blocker does not reproduce in the current corpus. The full production-route run found zero pairs with exactly one `generation_lifecycle` event on each side, different anchors, equal non-anchor lifecycle content, equal message content, equal attachment content, and an event-axis conflict. No comparison-layer exception is justified by this snapshot. + +The historical semantic risk remains real in principle and is preserved by the red-twin regression in `tests/unit/archive/test_session_revision_membership.py`. That test keeps a moved-anchor pair with different lifecycle state as a conflict. The existing parser fix `b1e01d878` already makes the ChatGPT generation-timing anchor deterministic, which is the upstream failure mode described by the bead. + +## Exact proof route + +The run used SQLite `mode=ro` connections to the current `source.db` and resolved `index.db`, read immutable blob files from the content-addressed blob store, then called the production functions directly in this order: + +```text +polylogue.sources.revision_backfill._parse_one(Provider.CHATGPT, payload, source_path, ...) +polylogue.pipeline.ids.session_revision_projection(session) +polylogue.archive.session_revision_membership._relation(left, right) +polylogue.archive.session_revision_membership.classify_membership_revisions(revisions, existing_accepted_raw_id=head) +``` + +The population was selected from `raw_sessions` with `origin='chatgpt-export'` and `revision_authority='quarantined'`. Cohorts were taken from persisted `raw_session_memberships.logical_source_key`, not reconstructed by SQL content heuristics. Singleton logical keys were retained in the population count and excluded only from pairwise comparison because a singleton cannot conflict. Current `raw_revision_heads` were read from the resolved index generation and passed to the classifier as `existing_accepted_raw_id`; no writeback or replay apply function was called. + +The exact population query was: + +```sql +SELECT raw_id, native_id, source_path, source_index, + lower(hex(blob_hash)) AS blob_hash, blob_size, + revision_kind, logical_source_key, revision_authority +FROM raw_sessions +WHERE origin = 'chatgpt-export' AND revision_authority = 'quarantined' +ORDER BY raw_id; +``` + +Membership keys came from: + +```sql +SELECT m.raw_id, m.logical_source_key +FROM raw_session_memberships AS m +JOIN raw_sessions AS r ON r.raw_id = m.raw_id +WHERE r.origin = 'chatgpt-export' AND r.revision_authority = 'quarantined' +ORDER BY m.logical_source_key, m.raw_id; +``` + +The transient full cohort report was `/realm/tmp/work/polylogue-uqwd-real-classifier-20260804.json`, SHA-256 `a00d15d0bcf49e6eee18613d419536a2f7217bc06b4de95bd7c6a479e629a49c`. The tracked packet below records the report's identity and decision-bearing results. + +## Corpus identity and coverage + +| Measure | Result | +| --- | ---: | +| Quarantined ChatGPT rows | 7,498 | +| Membership rows | 7,498 | +| Logical keys | 2,570 | +| Singleton logical keys | 15 | +| Multi-candidate logical keys | 2,555 | +| Rows in multi-candidate keys | 7,483 | +| Distinct non-null native ids | 2,464 | +| Rows with null native id | 5,021 | +| Candidate raws expected and parsed | 7,483 / 7,483 | +| Sessions returned and projected | 7,483 / 7,483 | +| Missing blobs | 0 | +| Parse errors | 0 | +| Parsed-key mismatches | 0 | +| Cohorts classified | 2,555 | + +Source identity at the run boundary: `/realm/db/polylogue/source.db`, resolved to the same path, device 63, inode 1515046, size 1,891,467,264 bytes, mtime ns 1785817591883760785, `user_version=24`, `schema_version=157`, WAL mode. + +Index identity at the run boundary: `/realm/db/polylogue/index.db` resolved to `/realm/db/polylogue/.index-generations/gen-1785377665711-06297b00/index.db`, device 63, inode 3789735, size 40,554,500,096 bytes, mtime ns 1785748371749663866, `user_version=46`, `schema_version=309`, WAL mode. + +## Classifier result + +Across all pairwise comparisons in the 2,555 multi-candidate cohorts, the production relation counts were `equal=7,348`, `a_contains_b=126`, `b_contains_a=82`, and `conflict=71`. Those 71 conflict pairs were distributed across 18 cohorts. The 18 conflict cohorts were not lifecycle-anchor drift: their shapes were message conflicts, attachment conflicts, event growth with missing lifecycle events, or many lifecycle events on both sides. The detailed shape census contained zero pairs with one lifecycle event on each side and moved anchor plus equal non-anchor content. + +The classifier returned an accepted raw for 2,554 cohorts, an ambiguous raw set for 7 cohorts, and equivalent raws for 2,538 cohorts. These are read-only simulated verdicts. The durable `raw_session_memberships.decision` values were not changed. + +As a cross-check of the narrower grouping used by the earlier evidence note, the current non-null `native_id` population has 5 multi-row cohorts and 18 rows. The same production parser, projection, and relation calls found `equal=8`, `a_contains_b=14`, `b_contains_a=5`, `conflict=0`, and lifecycle-anchor candidates `0`. + +## Historical cohorts named by the bead + +The premise that the named cohorts disappeared is false in the current snapshot. A read-only fragment query finds all three native ids in `raw_sessions`, and a membership query finds three rows for each logical key: + +```sql +SELECT m.raw_id, m.logical_source_key, m.provider_session_id, + m.decision, r.native_id, r.source_path, r.source_index +FROM raw_session_memberships AS m +JOIN raw_sessions AS r ON r.raw_id = m.raw_id +WHERE m.logical_source_key LIKE '%687f4424%' + OR m.logical_source_key LIKE '%6898a012%' + OR m.logical_source_key LIKE '%689b90d9%' +ORDER BY m.logical_source_key, m.raw_id; +``` + +Current real-parser outcomes for those three cohorts are one accepted raw plus two equivalent raws each, with no pairwise conflict: + +| Logical key fragment | Current parsed shape | Lifecycle-anchor result | Classifier result | +| --- | --- | --- | --- | +| `687f4424` | 46 messages, 1 lifecycle event, 3 revisions | all three anchor `986a3a7e-6e2b-4fba-851f-ab308fe52fda`; identical event-content fingerprint | 1 accepted, 2 equivalent | +| `6898a012` | 1,372 messages, 60 lifecycle events, 3 revisions | all three revisions have the same 60-event content fingerprint; not the one-event target shape | 1 accepted, 2 equivalent | +| `689b90d9` | 19 messages, 1 lifecycle event, 3 revisions | all three anchor `7c7fad67-9702-4a8b-8538-489a7b345e5a`; identical event-content fingerprint | 1 accepted, 2 equivalent | + +The current data therefore shows the original conflict has disappeared through stable parser output, not through a comparison exception. Commit `b1e01d878` changed `_extract_generation_timings` to use the candidate message id instead of mapping iteration position as the final tiebreak and added a parser regression for mapping-order stability. That is the recorded upstream explanation for why the moved anchor no longer changes across equivalent exports. The exact reason the 2026-08-03 read-only note failed to locate these rows cannot be established from this snapshot alone. The current evidence supersedes that negative lookup: the rows are now present and clean under the current parser. + +## Red-twin safeguard + +The added test constructs two ChatGPT-shaped revisions with one lifecycle event each, different anchors, and different `state` content. It calls `session_revision_projection`, `_relation`, and `classify_membership_revisions` with an existing head. It asserts equal message content, different event content, a `conflict` relation, no accepted replacement, and both raws remaining ambiguous. This is the minimal conditional regression needed if a future change revisits the proposed exactly-one-orphan comparison exception. + +## Acceptance match + +| Acceptance criterion | Status | Evidence | +| --- | --- | --- | +| Run the real classifier and projection path against current quarantined ChatGPT data | Satisfied | 7,483/7,483 candidate raws parsed and projected through production functions; 0 missing blobs, parse errors, or key mismatches | +| Confirm moved lifecycle anchors still produce conflicts | Not reproduced | 0 target-shaped pairs across 2,555 logical-key cohorts; named historical cohorts are clean | +| Implement the narrow comparison exception if reproduced | Not applicable | No production code change made | +| Preserve different-content moved-anchor behavior | Satisfied | Red-twin production-route regression added | +| Reclassify the current blocker edge honestly | Recommended | Current measurable blocker claim is unsupported; retain the historical semantic-risk note until a pre-fix replay artifact exists | + +## Graph disposition and residual uncertainty + +The `xselt/818fy` blocker edge is not justified as a current reindex blocker by this corpus. It is reasonable to retain it only as a historical semantic-risk reference until the graph owner reclassifies it. Objective evidence for removing the blocker edge is: the full current production-route census remains zero for the target predicate, the three named cohorts remain accepted/equivalent with stable anchors, the red twin remains green, and an archived pre-`b1e01d878` replay or fixture demonstrates the old moved-anchor conflict and the current parser's correction. The current packet does not fabricate that pre-fix replay because historical parser execution and backup access were outside this lane. + +Residual uncertainty is limited to provenance of the archive transition between the 2026-08-03 negative lookup and this 2026-08-04 snapshot, and the absence of an archived pre-fix parser output for the original 10/136 sample. The live durable membership decisions remain unchanged because this lane performed no writeback. diff --git a/docs/audits/README.md b/docs/audits/README.md index fad5bc9c00..5c08dcca19 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -13,3 +13,4 @@ and [Developer Tools](../devtools.md) references for present-tense behavior. - [Reindex forcing-class audit](2026-08-04-reindex-forcing-class-audit.md) - [Blob-reference liveness closure audit](2026-08-04-blob-ref-liveness-closure.md) - [Raw-failure preflight](2026-08-04-raw-failure-preflight.md) +- [ChatGPT lifecycle-anchor evidence packet](2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md) diff --git a/tests/unit/archive/test_session_revision_membership.py b/tests/unit/archive/test_session_revision_membership.py index 267557b245..2524665f8c 100644 --- a/tests/unit/archive/test_session_revision_membership.py +++ b/tests/unit/archive/test_session_revision_membership.py @@ -619,6 +619,51 @@ def test_refuses_generation_lifecycle_state_change_despite_duration_tolerance() assert result.ambiguous_raw_ids == ("raw-new",) +def test_refuses_different_content_moved_generation_lifecycle_anchor() -> None: + """A moved anchor does not excuse different lifecycle content. + + This is the red twin for the proposed, deliberately unlanded comparison + exception in polylogue-uqwd. A future exception may fold one orphaned + lifecycle event per side only when the non-anchor content is equal. It + must keep this different-state pair as a conflict even though the event + anchor moves between revisions. + """ + + def revision(raw_id: str, anchor: str, state: str) -> MembershipRevision: + session = ParsedSession( + source_name=Provider.CHATGPT, + provider_session_id="session", + messages=[ParsedMessage(provider_message_id="0", role=Role.ASSISTANT, text="answer")], + session_events=[ + ParsedSessionEvent( + event_type="generation_lifecycle", + timestamp="13.0", + source_message_provider_id=anchor, + payload={ + "state": state, + "evidence_source": "provider_native", + "fidelity": "exact", + "duration_semantics": "provider_reported_elapsed", + "elapsed_duration_ms": 13000, + }, + ) + ], + ) + return MembershipRevision(raw_id, session_revision_projection(session)) + + left = revision("raw-left", "anchor-left", "completed") + right = revision("raw-right", "anchor-right", "in_progress") + + assert left.projection.message_contents == right.projection.message_contents + assert left.projection.event_contents != right.projection.event_contents + assert _relation(left.projection, right.projection) == "conflict" + + result = classify_membership_revisions([left, right], existing_accepted_raw_id="raw-left") + + assert result.accepted_raw_ids == () + assert result.ambiguous_raw_ids == ("raw-left", "raw-right") + + def test_non_allowlisted_event_type_keeps_its_full_payload_as_content() -> None: """The allowlist is scoped to `generation_lifecycle` -- an unrelated event type with no registered allowlist compares its FULL payload, so a real From b7397d1c20e38dcdcae0a6898b53188211b49378 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 4 Aug 2026 11:15:44 +0200 Subject: [PATCH 2/6] test(audit): make ChatGPT anchor census reproducible Problem: the PR's current-corpus conclusion depended on an untracked report, and its direct ParsedSession test did not exercise the historical parser ordering defect. What changed: add a read-only production-route census command, a real SQLite archive regression, an end-to-end parser-to-classifier ordering test, generated command reference, and a packet that preserves graph gates pending a historical replay fixture. Compatibility/migration: no archive, blob, daemon, service, or Beads state is changed. Co-Authored-By: Claude --- devtools/chatgpt_lifecycle_anchor_audit.py | 250 ++++++++++++++++++ devtools/command_catalog.py | 14 + ...polylogue-uqwd-chatgpt-lifecycle-anchor.md | 112 ++------ docs/devtools.md | 1 + .../test_session_revision_membership.py | 10 +- .../test_chatgpt_lifecycle_anchor_audit.py | 164 ++++++++++++ tests/unit/sources/test_parsers_chatgpt.py | 41 +++ 7 files changed, 490 insertions(+), 102 deletions(-) create mode 100644 devtools/chatgpt_lifecycle_anchor_audit.py create mode 100644 tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py diff --git a/devtools/chatgpt_lifecycle_anchor_audit.py b/devtools/chatgpt_lifecycle_anchor_audit.py new file mode 100644 index 0000000000..4daf343b5d --- /dev/null +++ b/devtools/chatgpt_lifecycle_anchor_audit.py @@ -0,0 +1,250 @@ +"""Read-only ChatGPT lifecycle-anchor census through the production parser route. + +This command audits whether quarantined ChatGPT revisions currently exhibit +the historical mapping-order failure: two exports with equal transcript and +lifecycle content but a different generation-lifecycle anchor. It does not +change archive state. The only optional write is a caller-selected, +sanitized JSON receipt outside the archive. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +import subprocess +from collections import Counter, defaultdict +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, TextIO + +from polylogue.archive.session_revision_membership import MembershipRevision, _relation, classify_membership_revisions +from polylogue.core.enums import Provider +from polylogue.pipeline.ids import session_revision_projection +from polylogue.sources.parsers.base import ParsedSession +from polylogue.sources.revision_backfill import _parse_one +from polylogue.storage.blob_store import BlobStore + +_Relation = Literal["equal", "a_contains_b", "b_contains_a", "conflict"] + +SCHEMA = "polylogue.chatgpt-lifecycle-anchor-audit.v1" +TARGET_PREDICATE = ( + "A pair in one persisted logical_source_key cohort where each parsed session has exactly one " + "generation_lifecycle event, their source_message_provider_id anchors differ, message_contents and " + "attachment_contents are equal, non-anchor lifecycle content hashes are equal, and the production _relation is conflict." +) +SELECTION_SQL = """ +SELECT r.raw_id, r.source_path, lower(hex(r.blob_hash)) AS blob_hash, + m.logical_source_key, m.provider_session_id +FROM raw_sessions AS r +JOIN raw_session_memberships AS m ON m.raw_id = r.raw_id +WHERE r.origin = 'chatgpt-export' AND r.revision_authority = 'quarantined' +ORDER BY m.logical_source_key, r.raw_id +""".strip() +POPULATION_SQL = """ +SELECT raw_id +FROM raw_sessions +WHERE origin = 'chatgpt-export' AND revision_authority = 'quarantined' +ORDER BY raw_id +""".strip() + + +@dataclass(frozen=True, slots=True) +class _RawMember: + raw_id: str + source_path: str + blob_hash: str + logical_source_key: str + provider_session_id: str + + +@dataclass(frozen=True, slots=True) +class _ParsedMember: + revision: MembershipRevision + session: ParsedSession + + +def _connect_read_only(path: Path) -> sqlite3.Connection: + return sqlite3.connect(f"file:{path}?mode=ro", uri=True) + + +def _database_provenance(conn: sqlite3.Connection, path: Path) -> dict[str, int]: + stat = path.stat() + return { + "size_bytes": stat.st_size, + "mtime_ns": stat.st_mtime_ns, + "sqlite_schema_version": int(conn.execute("PRAGMA schema_version").fetchone()[0]), + "sqlite_user_version": int(conn.execute("PRAGMA user_version").fetchone()[0]), + } + + +def _git_revision() -> str | None: + repo_root = Path(__file__).resolve().parents[1] + try: + return subprocess.check_output( + ["git", "-C", os.fspath(repo_root), "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL + ).strip() + except (OSError, subprocess.CalledProcessError): + return None + + +def _matches_target(left: _ParsedMember, right: _ParsedMember, relation: _Relation) -> bool: + if len(left.session.session_events) != 1 or len(right.session.session_events) != 1: + return False + left_event, right_event = left.session.session_events[0], right.session.session_events[0] + left_projection = left.revision.projection + right_projection = right.revision.projection + return ( + left_event.event_type == right_event.event_type == "generation_lifecycle" + and left_event.source_message_provider_id != right_event.source_message_provider_id + and left_projection.message_contents == right_projection.message_contents + and left_projection.attachment_contents == right_projection.attachment_contents + and left_projection.event_contents != right_projection.event_contents + and {content for _, content in left_projection.event_contents} + == {content for _, content in right_projection.event_contents} + and relation == "conflict" + ) + + +def _load_existing_heads(index_conn: sqlite3.Connection) -> dict[str, str]: + return { + str(row[0]): str(row[1]) + for row in index_conn.execute("SELECT logical_source_key, accepted_raw_id FROM raw_revision_heads") + } + + +def _parse_member(member: _RawMember, blob_store: BlobStore, archive_root: Path) -> _ParsedMember: + sessions = _parse_one( + Provider.CHATGPT, + blob_store.read_all(member.blob_hash), + member.source_path, + archive_root=archive_root, + fallback_id_override=member.provider_session_id, + ) + matches = [session for session in sessions if session.provider_session_id == member.provider_session_id] + if len(matches) != 1: + raise RuntimeError( + "ChatGPT lifecycle-anchor audit expected one parsed session for a persisted membership row, " + f"got {len(matches)}" + ) + session = matches[0] + return _ParsedMember(MembershipRevision(member.raw_id, session_revision_projection(session)), session) + + +def _cohorts(rows: Iterable[_RawMember]) -> dict[str, list[_RawMember]]: + grouped: dict[str, list[_RawMember]] = defaultdict(list) + for row in rows: + grouped[row.logical_source_key].append(row) + return dict(grouped) + + +def run_audit(archive_root: Path) -> dict[str, object]: + """Run the full current-corpus census without opening an archive writer.""" + source_db = archive_root / "source.db" + index_db = archive_root / "index.db" + blob_store = BlobStore(archive_root / "blob") + source_conn = _connect_read_only(source_db) + index_conn = _connect_read_only(index_db) + try: + population_raw_ids = {str(row[0]) for row in source_conn.execute(POPULATION_SQL)} + rows = [_RawMember(*map(str, row)) for row in source_conn.execute(SELECTION_SQL)] + rows_by_raw_id: dict[str, list[_RawMember]] = defaultdict(list) + for row in rows: + rows_by_raw_id[row.raw_id].append(row) + duplicated_membership_raw_count = sum(1 for members in rows_by_raw_id.values() if len(members) != 1) + if duplicated_membership_raw_count: + raise RuntimeError("ChatGPT lifecycle-anchor audit requires exactly one membership row per selected raw") + cohorts = _cohorts(rows) + relation_counts: Counter[str] = Counter() + classifier_counts: Counter[str] = Counter() + target_pair_count = 0 + parsed_raw_count = 0 + heads = _load_existing_heads(index_conn) + for logical_source_key in sorted(cohorts): + revisions = [ + _parse_member(member, blob_store, archive_root) + for member in sorted(cohorts[logical_source_key], key=lambda member: member.raw_id) + ] + parsed_raw_count += len(revisions) + for index, left in enumerate(revisions): + for right in revisions[index + 1 :]: + relation = _relation(left.revision.projection, right.revision.projection) + relation_counts[relation] += 1 + if _matches_target(left, right, relation): + target_pair_count += 1 + classification = classify_membership_revisions( + [revision.revision for revision in revisions], existing_accepted_raw_id=heads.get(logical_source_key) + ) + classifier_counts["cohorts_with_accepted_raw"] += bool(classification.accepted_raw_ids) + classifier_counts["cohorts_with_equivalent_raw"] += bool(classification.equivalent_raw_ids) + classifier_counts["cohorts_with_ambiguous_raw"] += bool(classification.ambiguous_raw_ids) + cohort_sizes = Counter(len(members) for members in cohorts.values()) + return { + "schema": SCHEMA, + "provenance": { + "archive_access": "SQLite source.db and index.db opened mode=ro; blob files read only; no archive writer created.", + "producer_git_revision": _git_revision(), + "production_route": [ + "polylogue.sources.revision_backfill._parse_one", + "polylogue.pipeline.ids.session_revision_projection", + "polylogue.archive.session_revision_membership._relation", + "polylogue.archive.session_revision_membership.classify_membership_revisions", + ], + "source_db": _database_provenance(source_conn, source_db), + "index_db": _database_provenance(index_conn, index_db), + }, + "selection": {"sql": SELECTION_SQL, "population_sql": POPULATION_SQL}, + "target_predicate": TARGET_PREDICATE, + "denominators": { + "selected_quarantined_chatgpt_raw_count": len(population_raw_ids), + "selected_membership_row_count": len(rows), + "membershipless_selected_raw_count": len(population_raw_ids - set(rows_by_raw_id)), + "logical_source_key_count": len(cohorts), + "singleton_cohort_count": cohort_sizes[1], + "multi_candidate_cohort_count": sum(count for size, count in cohort_sizes.items() if size > 1), + "raws_in_multi_candidate_cohorts": sum( + size * count for size, count in cohort_sizes.items() if size > 1 + ), + "parsed_and_projected_raw_count": parsed_raw_count, + }, + "outcomes": { + "pair_relation_counts": { + name: relation_counts[name] for name in ("equal", "a_contains_b", "b_contains_a", "conflict") + }, + "target_pair_count": target_pair_count, + "classifier_cohort_counts": dict(sorted(classifier_counts.items())), + }, + "scope": { + "sanitized": "No raw ids, native ids, source paths, blob hashes, titles, or payload content are emitted.", + "conclusion_limit": ( + "A zero target_pair_count describes only this current parser-and-corpus snapshot. It does not establish " + "the historical pre-fix replay required to reclassify or remove any graph gate." + ), + }, + } + finally: + index_conn.close() + source_conn.close() + + +def _write_receipt(path: Path, receipt: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n") + + +def main(argv: list[str] | None = None, *, stdout: TextIO | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--archive-root", type=Path, required=True, help="Archive root to inspect without mutation.") + parser.add_argument("--receipt", type=Path, help="Optional worktree-local path for the sanitized JSON receipt.") + args = parser.parse_args(argv) + receipt = run_audit(args.archive_root) + if args.receipt is not None: + _write_receipt(args.receipt, receipt) + print(json.dumps(receipt, indent=2, sort_keys=True), file=stdout) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/devtools/command_catalog.py b/devtools/command_catalog.py index df9fc1acae..659156a5c4 100644 --- a/devtools/command_catalog.py +++ b/devtools/command_catalog.py @@ -1001,6 +1001,20 @@ def to_dict(self) -> dict[str, object]: "devtools workspace raw-live-source-reconciliation --limit 500 --sample-limit 20", ), ), + CommandSpec( + "workspace chatgpt-lifecycle-anchor-audit", + "workspace", + "Census the current quarantined ChatGPT corpus for lifecycle-anchor conflicts.", + "devtools.chatgpt_lifecycle_anchor_audit", + use_when=( + "Run the read-only parser-to-classifier census behind the historical ChatGPT mapping-order defect. " + "It emits only aggregate, sanitized evidence and never reclassifies source rows or graph gates." + ), + examples=( + "devtools workspace chatgpt-lifecycle-anchor-audit --archive-root /path/to/archive", + "devtools workspace chatgpt-lifecycle-anchor-audit --archive-root /path/to/archive --receipt docs/audits/receipt.json", + ), + ), CommandSpec( "workspace raw-live-source-reconciliation-apply", "workspace", diff --git a/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md index 1927e363e2..2687f6d2a2 100644 --- a/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md +++ b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md @@ -1,121 +1,41 @@ # polylogue-uqwd evidence packet: ChatGPT lifecycle-anchor drift -Date: 2026-08-04. Worktree: `feature/investigate/chatgpt-lifecycle-anchor` at `f7b721ab38538c82021d417faea363ba152c8758`. Scope: resolve whether the current quarantined ChatGPT population still reproduces the `generation_lifecycle` moved-anchor conflict described by polylogue-uqwd, without changing Beads, source.db, index.db, the blob store, backups, daemon state, or services. +Date: 2026-08-04. Worktree: `feature/fix/chatgpt-anchor-audit`. Scope: record a reproducible current-corpus census for the `generation_lifecycle` moved-anchor conflict without changing Beads, source.db, index.db, the blob store, backups, daemon state, or services. ## Verdict -The blocker does not reproduce in the current corpus. The full production-route run found zero pairs with exactly one `generation_lifecycle` event on each side, different anchors, equal non-anchor lifecycle content, equal message content, equal attachment content, and an event-axis conflict. No comparison-layer exception is justified by this snapshot. +The prior census was only an untracked `/realm/tmp` report, so its current-corpus conclusion was not independently auditable. This packet is now backed by the committed, reproducible `devtools workspace chatgpt-lifecycle-anchor-audit` command and its sanitized receipt at `docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json`. -The historical semantic risk remains real in principle and is preserved by the red-twin regression in `tests/unit/archive/test_session_revision_membership.py`. That test keeps a moved-anchor pair with different lifecycle state as a conflict. The existing parser fix `b1e01d878` already makes the ChatGPT generation-timing anchor deterministic, which is the upstream failure mode described by the bead. +The historical semantic risk remains real in principle. `tests/unit/sources/test_parsers_chatgpt.py` now carries an end-to-end regression from two mapping orders through the ChatGPT parser, revision projection, relation, and classifier. The direct `ParsedSession` test in `tests/unit/archive/test_session_revision_membership.py` remains only as a classifier-only different-content guard. -## Exact proof route +The receipt does **not** de-gate `uqwd`, `xselt`, or `818fy`. A zero result from the current parser and corpus is insufficient to establish the bead's required historical replay fixture. No graph state, archive state, blob store, daemon state, or service state was written by this work. -The run used SQLite `mode=ro` connections to the current `source.db` and resolved `index.db`, read immutable blob files from the content-addressed blob store, then called the production functions directly in this order: +## Reproducible receipt -```text -polylogue.sources.revision_backfill._parse_one(Provider.CHATGPT, payload, source_path, ...) -polylogue.pipeline.ids.session_revision_projection(session) -polylogue.archive.session_revision_membership._relation(left, right) -polylogue.archive.session_revision_membership.classify_membership_revisions(revisions, existing_accepted_raw_id=head) -``` +The command opens `source.db` and `index.db` with SQLite `mode=ro`, reads blobs, then invokes `_parse_one`, `session_revision_projection`, `_relation`, and `classify_membership_revisions`. It selects persisted `raw_session_memberships.logical_source_key` cohorts, reads current `raw_revision_heads` as classifier heads, and does not call a replay or writeback function. The exact SQL, predicate, code revision, schema versions, file sizes and mtimes are in the receipt. -The population was selected from `raw_sessions` with `origin='chatgpt-export'` and `revision_authority='quarantined'`. Cohorts were taken from persisted `raw_session_memberships.logical_source_key`, not reconstructed by SQL content heuristics. Singleton logical keys were retained in the population count and excluded only from pairwise comparison because a singleton cannot conflict. Current `raw_revision_heads` were read from the resolved index generation and passed to the classifier as `existing_accepted_raw_id`; no writeback or replay apply function was called. +The receipt deliberately contains no raw ids, native ids, source paths, blob hashes, titles, or payload content. It records 7,498 selected quarantined ChatGPT raws, 2,570 persisted cohorts, 15 singleton cohorts, 2,555 multi-candidate cohorts, and 7,483 raws in those multi-candidate cohorts. All 7,498 selected raws were parsed and projected. -The exact population query was: +Across pairwise comparisons the production relation counts were `equal=7,348`, `a_contains_b=126`, `b_contains_a=82`, and `conflict=71`. The target predicate count was zero: no pair had exactly one `generation_lifecycle` event per side, different anchors, equal transcript and attachment content, equal non-anchor lifecycle content, and a conflict relation. This does not imply that the remaining conflicts are harmless or that historical gates can be removed. -```sql -SELECT raw_id, native_id, source_path, source_index, - lower(hex(blob_hash)) AS blob_hash, blob_size, - revision_kind, logical_source_key, revision_authority -FROM raw_sessions -WHERE origin = 'chatgpt-export' AND revision_authority = 'quarantined' -ORDER BY raw_id; -``` +## Regression safeguards -Membership keys came from: +The end-to-end regression constructs two otherwise identical ChatGPT export mappings with different insertion orders, then invokes the real parser, projection, relation, and classifier. It asserts a common anchor, an equal relation, one accepted raw, one equivalent raw, and no ambiguity. It fails against the historical position-based tie-break. -```sql -SELECT m.raw_id, m.logical_source_key -FROM raw_session_memberships AS m -JOIN raw_sessions AS r ON r.raw_id = m.raw_id -WHERE r.origin = 'chatgpt-export' AND r.revision_authority = 'quarantined' -ORDER BY m.logical_source_key, m.raw_id; -``` - -The transient full cohort report was `/realm/tmp/work/polylogue-uqwd-real-classifier-20260804.json`, SHA-256 `a00d15d0bcf49e6eee18613d419536a2f7217bc06b4de95bd7c6a479e629a49c`. The tracked packet below records the report's identity and decision-bearing results. - -## Corpus identity and coverage - -| Measure | Result | -| --- | ---: | -| Quarantined ChatGPT rows | 7,498 | -| Membership rows | 7,498 | -| Logical keys | 2,570 | -| Singleton logical keys | 15 | -| Multi-candidate logical keys | 2,555 | -| Rows in multi-candidate keys | 7,483 | -| Distinct non-null native ids | 2,464 | -| Rows with null native id | 5,021 | -| Candidate raws expected and parsed | 7,483 / 7,483 | -| Sessions returned and projected | 7,483 / 7,483 | -| Missing blobs | 0 | -| Parse errors | 0 | -| Parsed-key mismatches | 0 | -| Cohorts classified | 2,555 | - -Source identity at the run boundary: `/realm/db/polylogue/source.db`, resolved to the same path, device 63, inode 1515046, size 1,891,467,264 bytes, mtime ns 1785817591883760785, `user_version=24`, `schema_version=157`, WAL mode. - -Index identity at the run boundary: `/realm/db/polylogue/index.db` resolved to `/realm/db/polylogue/.index-generations/gen-1785377665711-06297b00/index.db`, device 63, inode 3789735, size 40,554,500,096 bytes, mtime ns 1785748371749663866, `user_version=46`, `schema_version=309`, WAL mode. - -## Classifier result - -Across all pairwise comparisons in the 2,555 multi-candidate cohorts, the production relation counts were `equal=7,348`, `a_contains_b=126`, `b_contains_a=82`, and `conflict=71`. Those 71 conflict pairs were distributed across 18 cohorts. The 18 conflict cohorts were not lifecycle-anchor drift: their shapes were message conflicts, attachment conflicts, event growth with missing lifecycle events, or many lifecycle events on both sides. The detailed shape census contained zero pairs with one lifecycle event on each side and moved anchor plus equal non-anchor content. - -The classifier returned an accepted raw for 2,554 cohorts, an ambiguous raw set for 7 cohorts, and equivalent raws for 2,538 cohorts. These are read-only simulated verdicts. The durable `raw_session_memberships.decision` values were not changed. - -As a cross-check of the narrower grouping used by the earlier evidence note, the current non-null `native_id` population has 5 multi-row cohorts and 18 rows. The same production parser, projection, and relation calls found `equal=8`, `a_contains_b=14`, `b_contains_a=5`, `conflict=0`, and lifecycle-anchor candidates `0`. - -## Historical cohorts named by the bead - -The premise that the named cohorts disappeared is false in the current snapshot. A read-only fragment query finds all three native ids in `raw_sessions`, and a membership query finds three rows for each logical key: - -```sql -SELECT m.raw_id, m.logical_source_key, m.provider_session_id, - m.decision, r.native_id, r.source_path, r.source_index -FROM raw_session_memberships AS m -JOIN raw_sessions AS r ON r.raw_id = m.raw_id -WHERE m.logical_source_key LIKE '%687f4424%' - OR m.logical_source_key LIKE '%6898a012%' - OR m.logical_source_key LIKE '%689b90d9%' -ORDER BY m.logical_source_key, m.raw_id; -``` - -Current real-parser outcomes for those three cohorts are one accepted raw plus two equivalent raws each, with no pairwise conflict: - -| Logical key fragment | Current parsed shape | Lifecycle-anchor result | Classifier result | -| --- | --- | --- | --- | -| `687f4424` | 46 messages, 1 lifecycle event, 3 revisions | all three anchor `986a3a7e-6e2b-4fba-851f-ab308fe52fda`; identical event-content fingerprint | 1 accepted, 2 equivalent | -| `6898a012` | 1,372 messages, 60 lifecycle events, 3 revisions | all three revisions have the same 60-event content fingerprint; not the one-event target shape | 1 accepted, 2 equivalent | -| `689b90d9` | 19 messages, 1 lifecycle event, 3 revisions | all three anchor `7c7fad67-9702-4a8b-8538-489a7b345e5a`; identical event-content fingerprint | 1 accepted, 2 equivalent | - -The current data therefore shows the original conflict has disappeared through stable parser output, not through a comparison exception. Commit `b1e01d878` changed `_extract_generation_timings` to use the candidate message id instead of mapping iteration position as the final tiebreak and added a parser regression for mapping-order stability. That is the recorded upstream explanation for why the moved anchor no longer changes across equivalent exports. The exact reason the 2026-08-03 read-only note failed to locate these rows cannot be established from this snapshot alone. The current evidence supersedes that negative lookup: the rows are now present and clean under the current parser. - -## Red-twin safeguard - -The added test constructs two ChatGPT-shaped revisions with one lifecycle event each, different anchors, and different `state` content. It calls `session_revision_projection`, `_relation`, and `classify_membership_revisions` with an existing head. It asserts equal message content, different event content, a `conflict` relation, no accepted replacement, and both raws remaining ambiguous. This is the minimal conditional regression needed if a future change revisits the proposed exactly-one-orphan comparison exception. +The retained direct classifier guard constructs `ParsedSession` values with different lifecycle `state` content and moved anchors. It asserts conflict and ambiguity with an existing head. Its scope is limited to classifier behavior and it intentionally does not cover parser ordering. ## Acceptance match | Acceptance criterion | Status | Evidence | | --- | --- | --- | -| Run the real classifier and projection path against current quarantined ChatGPT data | Satisfied | 7,483/7,483 candidate raws parsed and projected through production functions; 0 missing blobs, parse errors, or key mismatches | -| Confirm moved lifecycle anchors still produce conflicts | Not reproduced | 0 target-shaped pairs across 2,555 logical-key cohorts; named historical cohorts are clean | +| Run the real classifier and projection path against current quarantined ChatGPT data | Reproducible current-corpus evidence | The committed command and sanitized receipt make the parser, projection, relation, classifier, SQL selection, denominators, and archive provenance reviewable | +| Confirm moved lifecycle anchors still produce conflicts | Historical fixture still required | The end-to-end regression reproduces the mapping-order mechanism, but the current snapshot does not substitute for the required archived pre-fix replay | | Implement the narrow comparison exception if reproduced | Not applicable | No production code change made | -| Preserve different-content moved-anchor behavior | Satisfied | Red-twin production-route regression added | -| Reclassify the current blocker edge honestly | Recommended | Current measurable blocker claim is unsupported; retain the historical semantic-risk note until a pre-fix replay artifact exists | +| Preserve different-content moved-anchor behavior | Satisfied, classifier scope only | The direct `ParsedSession` guard keeps changed lifecycle content conflicting; the parser-to-classifier regression owns ordering behavior | +| Reclassify the current blocker edge honestly | Not satisfied | This packet makes no reclassification or de-gating recommendation until the retained historical replay fixture exists | ## Graph disposition and residual uncertainty -The `xselt/818fy` blocker edge is not justified as a current reindex blocker by this corpus. It is reasonable to retain it only as a historical semantic-risk reference until the graph owner reclassifies it. Objective evidence for removing the blocker edge is: the full current production-route census remains zero for the target predicate, the three named cohorts remain accepted/equivalent with stable anchors, the red twin remains green, and an archived pre-`b1e01d878` replay or fixture demonstrates the old moved-anchor conflict and the current parser's correction. The current packet does not fabricate that pre-fix replay because historical parser execution and backup access were outside this lane. +No graph-edge conclusion is made here. The current-corpus receipt and regression establish that the active parser is protected against the known ordering bug, but they do not provide a retained pre-`b1e01d878` historical replay fixture. The packet therefore preserves the no-de-gating stance for `uqwd`, `xselt`, and `818fy`. Residual uncertainty is limited to provenance of the archive transition between the 2026-08-03 negative lookup and this 2026-08-04 snapshot, and the absence of an archived pre-fix parser output for the original 10/136 sample. The live durable membership decisions remain unchanged because this lane performed no writeback. diff --git a/docs/devtools.md b/docs/devtools.md index 71276d52a7..1eb9e7ce94 100644 --- a/docs/devtools.md +++ b/docs/devtools.md @@ -232,6 +232,7 @@ These are the commands worth remembering during normal repo work: | `devtools workspace beads-state-report` | Self-contained HTML state-of-the-backlog report over the whole bead population. | | `devtools workspace binary-artifact-reclassify-apply` | Persist raw_artifacts classification for binary-shaped raw rows. | | `devtools workspace binary-artifact-sweep` | Find raw_sessions rows whose bytes are a non-session binary format (SQLite, etc). | +| `devtools workspace chatgpt-lifecycle-anchor-audit` | Census the current quarantined ChatGPT corpus for lifecycle-anchor conflicts. | | `devtools workspace claim-vs-evidence` | Build a structured failure follow-up claim-vs-evidence demo. | | `devtools workspace cli-surface-audit` | Capture a current-curated CLI surface audit demo. | | `devtools workspace degraded-archive-proof` | Build a degraded archive self-healing proof artifact. | diff --git a/tests/unit/archive/test_session_revision_membership.py b/tests/unit/archive/test_session_revision_membership.py index 2524665f8c..052ad7b545 100644 --- a/tests/unit/archive/test_session_revision_membership.py +++ b/tests/unit/archive/test_session_revision_membership.py @@ -620,13 +620,11 @@ def test_refuses_generation_lifecycle_state_change_despite_duration_tolerance() def test_refuses_different_content_moved_generation_lifecycle_anchor() -> None: - """A moved anchor does not excuse different lifecycle content. + """Classifier-only guard: a moved anchor does not excuse changed content. - This is the red twin for the proposed, deliberately unlanded comparison - exception in polylogue-uqwd. A future exception may fold one orphaned - lifecycle event per side only when the non-anchor content is equal. It - must keep this different-state pair as a conflict even though the event - anchor moves between revisions. + This deliberately constructs ParsedSession values and therefore does not + cover ChatGPT parser selection or mapping-order stability. The end-to-end + regression in test_parsers_chatgpt.py owns that upstream contract. """ def revision(raw_id: str, anchor: str, state: str) -> MembershipRevision: diff --git a/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py b/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py new file mode 100644 index 0000000000..d019fdd0c3 --- /dev/null +++ b/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import cast + +import pytest + +from devtools.chatgpt_lifecycle_anchor_audit import SCHEMA, TARGET_PREDICATE, main, run_audit +from devtools.command_catalog import COMMANDS +from polylogue.core.enums import Origin, Provider +from polylogue.storage.blob_store import BlobStore +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root +from polylogue.storage.sqlite.archive_tiers.source_write import write_source_raw_session + + +def _node(node_id: str, role: str, text: str, parent: str | None, children: list[str]) -> dict[str, object]: + return { + "id": node_id, + "parent": parent, + "children": children, + "message": { + "id": node_id, + "author": {"role": role}, + "content": {"content_type": "text", "parts": [text]}, + "metadata": {"finished_duration_sec": 5} if role == "assistant" else {}, + "end_turn": role == "assistant", + }, + } + + +def _payload(order: list[str]) -> bytes: + nodes = { + "u1": _node("u1", "user", "do the work", None, ["node_a"]), + "node_a": _node("node_a", "assistant", "first draft", "u1", ["node_b"]), + "node_b": _node("node_b", "assistant", "final draft", "node_a", []), + } + return json.dumps( + {"id": "tie-break-order", "mapping": {node_id: nodes[node_id] for node_id in order}, "current_node": "node_b"}, + separators=(",", ":"), + ).encode() + + +def _write_raw(source: sqlite3.Connection, *, raw_id: str, payload: bytes) -> None: + write_source_raw_session( + source, + origin=Origin.CHATGPT_EXPORT, + capture_mode=Provider.CHATGPT, + payload=payload, + source_path="/redacted/chatgpt-export.json", + source_index=0, + acquired_at_ms=1, + raw_id=raw_id, + ) + source.execute( + """ + INSERT INTO raw_session_memberships( + raw_id, logical_source_key, provider_session_id, source_revision, + normalized_content_hash, message_count, revision_authority + ) VALUES (?, 'chatgpt-export:tie-break-order', 'tie-break-order', ?, ?, 3, 'quarantined') + """, + (raw_id, raw_id, b"x" * 32), + ) + + +def _archive_with_ordered_exports(tmp_path: Path) -> Path: + root = tmp_path / "archive" + initialize_active_archive_root(root) + source = sqlite3.connect(root / "source.db") + try: + blob_store = BlobStore(root / "blob") + for payload in (_payload(["u1", "node_a", "node_b"]), _payload(["u1", "node_b", "node_a"])): + blob_store.write_from_bytes(payload) + _write_raw(source, raw_id="raw-left", payload=_payload(["u1", "node_a", "node_b"])) + _write_raw(source, raw_id="raw-right", payload=_payload(["u1", "node_b", "node_a"])) + source.commit() + finally: + source.close() + index = sqlite3.connect(root / "index.db") + try: + index.execute( + """ + INSERT INTO raw_revision_heads( + logical_source_key, session_id, accepted_raw_id, accepted_source_revision, + accepted_content_hash, accepted_frontier_kind, accepted_frontier, + acquisition_generation, append_end_offset, decided_at_ms + ) VALUES ('chatgpt-export:tie-break-order', 'chatgpt-export:tie-break-order', 'raw-left', + 'raw-left', ?, 'semantic', ?, 0, NULL, 0) + """, + (b"y" * 32, 0), + ) + index.commit() + finally: + index.close() + return root + + +def test_audit_runs_the_parser_to_classifier_route_read_only_and_is_sanitized(tmp_path: Path) -> None: + root = _archive_with_ordered_exports(tmp_path) + source_before = (root / "source.db").read_bytes() + index_before = (root / "index.db").read_bytes() + + receipt = run_audit(root) + + assert receipt["schema"] == SCHEMA + assert receipt["target_predicate"] == TARGET_PREDICATE + assert receipt["denominators"] == { + "selected_quarantined_chatgpt_raw_count": 2, + "selected_membership_row_count": 2, + "membershipless_selected_raw_count": 0, + "logical_source_key_count": 1, + "singleton_cohort_count": 0, + "multi_candidate_cohort_count": 1, + "raws_in_multi_candidate_cohorts": 2, + "parsed_and_projected_raw_count": 2, + } + outcomes = cast(dict[str, object], receipt["outcomes"]) + assert cast(dict[str, int], outcomes["pair_relation_counts"]) == { + "equal": 1, + "a_contains_b": 0, + "b_contains_a": 0, + "conflict": 0, + } + assert outcomes["target_pair_count"] == 0 + rendered = json.dumps(receipt, sort_keys=True) + assert "raw-left" not in rendered + assert "raw-right" not in rendered + assert "/redacted/chatgpt-export.json" not in rendered + assert (root / "source.db").read_bytes() == source_before + assert (root / "index.db").read_bytes() == index_before + + +def test_audit_receipt_is_deterministic_and_cli_registers_the_command( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + root = _archive_with_ordered_exports(tmp_path) + first = run_audit(root) + second = run_audit(root) + assert first == second + receipt_path = tmp_path / "receipt.json" + + assert main(["--archive-root", str(root), "--receipt", str(receipt_path)]) == 0 + assert json.loads(receipt_path.read_text()) == first + assert json.loads(capsys.readouterr().out) == first + command = COMMANDS["workspace chatgpt-lifecycle-anchor-audit"] + assert command.module == "devtools.chatgpt_lifecycle_anchor_audit" + + +def test_audit_requires_a_real_sqlite_archive(tmp_path: Path) -> None: + root = tmp_path / "archive" + root.mkdir() + (root / "source.db").touch() + (root / "index.db").touch() + with sqlite3.connect(root / "source.db") as conn: + conn.execute("CREATE TABLE raw_sessions(raw_id TEXT)") + with sqlite3.connect(root / "index.db") as conn: + conn.execute("CREATE TABLE raw_revision_heads(logical_source_key TEXT, accepted_raw_id TEXT)") + try: + run_audit(root) + except sqlite3.OperationalError as error: + assert "origin" in str(error) + else: # pragma: no cover + raise AssertionError("audit accepted an archive without the production source schema") diff --git a/tests/unit/sources/test_parsers_chatgpt.py b/tests/unit/sources/test_parsers_chatgpt.py index c3bfbcb39c..44514c5cd1 100644 --- a/tests/unit/sources/test_parsers_chatgpt.py +++ b/tests/unit/sources/test_parsers_chatgpt.py @@ -1557,6 +1557,47 @@ def _anchor(order: list[dict[str, Any]]) -> str | None: assert anchor_forward == anchor_reversed +def test_chatgpt_mapping_order_does_not_create_revision_conflict() -> None: + """The parser's stable tie-break reaches the membership classifier. + + The historical implementation used mapping insertion position as the final + timing-candidate tiebreak. The two otherwise identical export orders then + anchored their lifecycle event to different messages, which made the + production revision classifier quarantine both raws as a conflict. + """ + from polylogue.archive.session_revision_membership import ( + MembershipRevision, + _relation, + classify_membership_revisions, + ) + + user = _branch_node("u1", "user", "do the work", parent=None, children=["node_a"]) + node_a = _branch_node("node_a", "assistant", "first draft", parent="u1", children=["node_b"]) + node_b = _branch_node("node_b", "assistant", "final draft", parent="node_a", children=[]) + for node in (node_a, node_b): + node["message"]["metadata"] = {"finished_duration_sec": 5} + + def parsed(order: list[dict[str, Any]]) -> ParsedSession: + return chatgpt_parse( + {"id": "tie-break-order", "mapping": {node["id"]: node for node in order}, "current_node": "node_b"}, + "fallback-id", + ) + + left, right = parsed([user, node_a, node_b]), parsed([user, node_b, node_a]) + revisions = [ + MembershipRevision(raw_id, session_revision_projection(session)) + for raw_id, session in (("raw-left", left), ("raw-right", right)) + ] + + assert left.session_events[0].source_message_provider_id == right.session_events[0].source_message_provider_id + assert revisions[0].projection.event_contents == revisions[1].projection.event_contents + assert _relation(revisions[0].projection, revisions[1].projection) == "equal" + result = classify_membership_revisions(revisions, existing_accepted_raw_id="raw-left") + assert result.accepted_raw_ids == ("raw-left",) + assert result.equivalent_raw_ids == ("raw-right",) + assert result.ambiguous_raw_ids == () + + # --------------------------------------------------------------------------- # #1744 — non-`parts` content is preserved (code interpreter, execution output) # --------------------------------------------------------------------------- From 2b708815ffdc36199441cfeaf6f910f01b7cd01d Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 4 Aug 2026 11:20:58 +0200 Subject: [PATCH 3/6] docs(audit): record ChatGPT anchor census receipt Problem: the current-corpus census needed a reviewable artifact tied to the committed production-route audit command. What changed: record the sanitized aggregate receipt with its SQL selection, predicate, denominators, source and index provenance, and explicit no-de-gating limit. Compatibility/migration: the receipt is evidence only. It performs no archive or service mutation. Co-Authored-By: Claude --- ...uqwd-chatgpt-lifecycle-anchor-receipt.json | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json diff --git a/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json new file mode 100644 index 0000000000..a4e24afa42 --- /dev/null +++ b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json @@ -0,0 +1,58 @@ +{ + "denominators": { + "logical_source_key_count": 2570, + "membershipless_selected_raw_count": 0, + "multi_candidate_cohort_count": 2555, + "parsed_and_projected_raw_count": 7498, + "raws_in_multi_candidate_cohorts": 7483, + "selected_membership_row_count": 7498, + "selected_quarantined_chatgpt_raw_count": 7498, + "singleton_cohort_count": 15 + }, + "outcomes": { + "classifier_cohort_counts": { + "cohorts_with_accepted_raw": 2561, + "cohorts_with_ambiguous_raw": 18, + "cohorts_with_equivalent_raw": 2538 + }, + "pair_relation_counts": { + "a_contains_b": 126, + "b_contains_a": 82, + "conflict": 71, + "equal": 7348 + }, + "target_pair_count": 0 + }, + "provenance": { + "archive_access": "SQLite source.db and index.db opened mode=ro; blob files read only; no archive writer created.", + "index_db": { + "mtime_ns": 1785748371749663866, + "size_bytes": 40554500096, + "sqlite_schema_version": 309, + "sqlite_user_version": 46 + }, + "producer_git_revision": "257b851f2ce4bde2d6501ea364a987718be8cac2", + "production_route": [ + "polylogue.sources.revision_backfill._parse_one", + "polylogue.pipeline.ids.session_revision_projection", + "polylogue.archive.session_revision_membership._relation", + "polylogue.archive.session_revision_membership.classify_membership_revisions" + ], + "source_db": { + "mtime_ns": 1785817591883760785, + "size_bytes": 1891467264, + "sqlite_schema_version": 157, + "sqlite_user_version": 24 + } + }, + "schema": "polylogue.chatgpt-lifecycle-anchor-audit.v1", + "scope": { + "conclusion_limit": "A zero target_pair_count describes only this current parser-and-corpus snapshot. It does not establish the historical pre-fix replay required to reclassify or remove any graph gate.", + "sanitized": "No raw ids, native ids, source paths, blob hashes, titles, or payload content are emitted." + }, + "selection": { + "population_sql": "SELECT raw_id\nFROM raw_sessions\nWHERE origin = 'chatgpt-export' AND revision_authority = 'quarantined'\nORDER BY raw_id", + "sql": "SELECT r.raw_id, r.source_path, lower(hex(r.blob_hash)) AS blob_hash,\n m.logical_source_key, m.provider_session_id\nFROM raw_sessions AS r\nJOIN raw_session_memberships AS m ON m.raw_id = r.raw_id\nWHERE r.origin = 'chatgpt-export' AND r.revision_authority = 'quarantined'\nORDER BY m.logical_source_key, r.raw_id" + }, + "target_predicate": "A pair in one persisted logical_source_key cohort where each parsed session has exactly one generation_lifecycle event, their source_message_provider_id anchors differ, message_contents and attachment_contents are equal, non-anchor lifecycle content hashes are equal, and the production _relation is conflict." +} From 922b233d954d4a9cfb7932bcf0d994d60de3f288 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 4 Aug 2026 23:29:10 +0200 Subject: [PATCH 4/6] fix(audit): bind moved-anchor census to real provenance Problem: The target predicate compared projection event identity that embeds the moved anchor, and the receipt did not identify the exact producer state or blob contents. The fixture also stopped before the parser-to-classifier historical conflict. What changed: Compare normalized event content with a lifecycle-only anchor exception, exercise the historical tiebreak through the production parser and classifier, and bind receipts to exact HEAD, worktree status, and read-only blob snapshot and integrity identities. Keep the checked-in receipt explicitly historical and retain all graph gates. Compatibility/migration: The receipt schema is v2 for newly generated audits. The checked-in v1 receipt remains historical evidence and is not treated as a rerun. Co-Authored-By: Claude --- devtools/chatgpt_lifecycle_anchor_audit.py | 158 ++++++++++++++-- ...uqwd-chatgpt-lifecycle-anchor-receipt.json | 2 + ...polylogue-uqwd-chatgpt-lifecycle-anchor.md | 20 +- .../test_chatgpt_lifecycle_anchor_audit.py | 178 ++++++++++++++++++ tests/unit/sources/test_parsers_chatgpt.py | 44 +++++ 5 files changed, 372 insertions(+), 30 deletions(-) diff --git a/devtools/chatgpt_lifecycle_anchor_audit.py b/devtools/chatgpt_lifecycle_anchor_audit.py index 4daf343b5d..f67664cad7 100644 --- a/devtools/chatgpt_lifecycle_anchor_audit.py +++ b/devtools/chatgpt_lifecycle_anchor_audit.py @@ -10,6 +10,7 @@ from __future__ import annotations import argparse +import hashlib import json import os import sqlite3 @@ -22,18 +23,21 @@ from polylogue.archive.session_revision_membership import MembershipRevision, _relation, classify_membership_revisions from polylogue.core.enums import Provider -from polylogue.pipeline.ids import session_revision_projection -from polylogue.sources.parsers.base import ParsedSession +from polylogue.core.hashing import hash_payload +from polylogue.pipeline.ids import _event_content_payload, session_revision_projection +from polylogue.sources.parsers.base import ParsedSession, ParsedSessionEvent from polylogue.sources.revision_backfill import _parse_one from polylogue.storage.blob_store import BlobStore _Relation = Literal["equal", "a_contains_b", "b_contains_a", "conflict"] -SCHEMA = "polylogue.chatgpt-lifecycle-anchor-audit.v1" +SCHEMA = "polylogue.chatgpt-lifecycle-anchor-audit.v2" TARGET_PREDICATE = ( "A pair in one persisted logical_source_key cohort where each parsed session has exactly one " - "generation_lifecycle event, their source_message_provider_id anchors differ, message_contents and " - "attachment_contents are equal, non-anchor lifecycle content hashes are equal, and the production _relation is conflict." + "generation_lifecycle event (other session events are allowed), their source_message_provider_id " + "anchors differ, message_contents and attachment_contents are equal, generation_lifecycle event " + "content hashes after removing source_message_provider_id are equal, all normalized event content " + "is equal after that same lifecycle-only exception, and the production _relation is conflict." ) SELECTION_SQL = """ SELECT r.raw_id, r.source_path, lower(hex(r.blob_hash)) AS blob_hash, @@ -80,30 +84,130 @@ def _database_provenance(conn: sqlite3.Connection, path: Path) -> dict[str, int] } -def _git_revision() -> str | None: +def _git_provenance() -> dict[str, object]: repo_root = Path(__file__).resolve().parents[1] try: - return subprocess.check_output( - ["git", "-C", os.fspath(repo_root), "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL + revision = subprocess.check_output( + ["git", "-C", os.fspath(repo_root), "rev-parse", "--verify", "HEAD"], + text=True, + stderr=subprocess.DEVNULL, + timeout=5, ).strip() - except (OSError, subprocess.CalledProcessError): - return None + status = subprocess.run( + ["git", "-C", os.fspath(repo_root), "status", "--porcelain=v1", "--untracked-files=all"], + capture_output=True, + check=True, + text=True, + timeout=5, + ).stdout + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + raise RuntimeError("ChatGPT lifecycle-anchor audit requires a readable git producer checkout") from error + return { + "git_revision": revision, + "working_tree_clean": not bool(status), + "working_tree_status_sha256": hashlib.sha256(status.encode("utf-8")).hexdigest(), + } + + +def _generation_events(session: ParsedSession) -> list[ParsedSessionEvent]: + return [event for event in session.session_events if event.event_type == "generation_lifecycle"] + + +def _anchor_independent_event_content(event: ParsedSessionEvent) -> bytes: + """Hash one lifecycle event without its provider-message anchor.""" + payload = _event_content_payload(event) + payload.pop("source_message_provider_id", None) + return bytes.fromhex(hash_payload(payload)) + + +def _event_content_signature(event: ParsedSessionEvent) -> bytes: + """Hash normalized event content, retaining anchors except for lifecycle events.""" + if event.event_type == "generation_lifecycle": + return _anchor_independent_event_content(event) + return bytes.fromhex(hash_payload(_event_content_payload(event))) + + +def _session_event_content_signatures(session: ParsedSession) -> Counter[bytes]: + """Return normalized event content as a multiset, independent of array order.""" + return Counter(_event_content_signature(event) for event in session.session_events) + + +def _blob_store_snapshot(blob_store: BlobStore) -> dict[str, object]: + """Capture a deterministic, read-only identity and integrity scan of blobs.""" + snapshot_digest = hashlib.sha256() + integrity_digest = hashlib.sha256() + canonical_blob_count = 0 + canonical_blob_bytes = 0 + verified_blob_count = 0 + hash_mismatch_count = 0 + invalid_namespace_entry_count = 0 + + for entry in blob_store.iter_namespace(): + if entry.hash_hex is None: + invalid_namespace_entry_count += 1 + record = { + "kind": entry.kind.value, + "issue": entry.issue.value if entry.issue is not None else None, + "relative_path": entry.relative_path, + } + encoded = json.dumps(record, sort_keys=True, separators=(",", ":")).encode("utf-8") + snapshot_digest.update(encoded) + snapshot_digest.update(b"\n") + integrity_digest.update(encoded) + integrity_digest.update(b"\n") + continue + + size_bytes = entry.path.stat().st_size + actual_digest = hashlib.sha256() + with entry.path.open("rb") as blob: + while chunk := blob.read(1024 * 1024): + actual_digest.update(chunk) + verified = actual_digest.hexdigest() == entry.hash_hex + canonical_blob_count += 1 + canonical_blob_bytes += size_bytes + verified_blob_count += int(verified) + hash_mismatch_count += int(not verified) + snapshot_record = {"hash": entry.hash_hex, "size_bytes": size_bytes} + integrity_record = { + **snapshot_record, + "verified": verified, + "observed_sha256": actual_digest.hexdigest(), + } + snapshot_encoded = json.dumps(snapshot_record, sort_keys=True, separators=(",", ":")).encode("utf-8") + integrity_encoded = json.dumps(integrity_record, sort_keys=True, separators=(",", ":")).encode("utf-8") + snapshot_digest.update(snapshot_encoded) + snapshot_digest.update(b"\n") + integrity_digest.update(integrity_encoded) + integrity_digest.update(b"\n") + + return { + "snapshot_sha256": snapshot_digest.hexdigest(), + "canonical_blob_count": canonical_blob_count, + "canonical_blob_bytes": canonical_blob_bytes, + "integrity": { + "scan": "full_read_only_namespace_and_content_hash", + "verified_blob_count": verified_blob_count, + "hash_mismatch_count": hash_mismatch_count, + "invalid_namespace_entry_count": invalid_namespace_entry_count, + "integrity_sha256": integrity_digest.hexdigest(), + }, + } def _matches_target(left: _ParsedMember, right: _ParsedMember, relation: _Relation) -> bool: - if len(left.session.session_events) != 1 or len(right.session.session_events) != 1: + left_generation_events = _generation_events(left.session) + right_generation_events = _generation_events(right.session) + if len(left_generation_events) != 1 or len(right_generation_events) != 1: return False - left_event, right_event = left.session.session_events[0], right.session.session_events[0] + left_event, right_event = left_generation_events[0], right_generation_events[0] left_projection = left.revision.projection right_projection = right.revision.projection return ( - left_event.event_type == right_event.event_type == "generation_lifecycle" - and left_event.source_message_provider_id != right_event.source_message_provider_id + left_event.source_message_provider_id != right_event.source_message_provider_id and left_projection.message_contents == right_projection.message_contents and left_projection.attachment_contents == right_projection.attachment_contents - and left_projection.event_contents != right_projection.event_contents - and {content for _, content in left_projection.event_contents} - == {content for _, content in right_projection.event_contents} + and _session_event_content_signatures(left.session) == _session_event_content_signatures(right.session) + and _anchor_independent_event_content(left_event) == _anchor_independent_event_content(right_event) and relation == "conflict" ) @@ -162,6 +266,8 @@ def run_audit(archive_root: Path) -> dict[str, object]: target_pair_count = 0 parsed_raw_count = 0 heads = _load_existing_heads(index_conn) + blob_snapshot = _blob_store_snapshot(blob_store) + producer = _git_provenance() for logical_source_key in sorted(cohorts): revisions = [ _parse_member(member, blob_store, archive_root) @@ -185,7 +291,9 @@ def run_audit(archive_root: Path) -> dict[str, object]: "schema": SCHEMA, "provenance": { "archive_access": "SQLite source.db and index.db opened mode=ro; blob files read only; no archive writer created.", - "producer_git_revision": _git_revision(), + "producer_git_revision": producer["git_revision"], + "producer_working_tree_clean": producer["working_tree_clean"], + "producer_working_tree_status_sha256": producer["working_tree_status_sha256"], "production_route": [ "polylogue.sources.revision_backfill._parse_one", "polylogue.pipeline.ids.session_revision_projection", @@ -194,6 +302,7 @@ def run_audit(archive_root: Path) -> dict[str, object]: ], "source_db": _database_provenance(source_conn, source_db), "index_db": _database_provenance(index_conn, index_db), + "blob_store": blob_snapshot, }, "selection": {"sql": SELECTION_SQL, "population_sql": POPULATION_SQL}, "target_predicate": TARGET_PREDICATE, @@ -239,9 +348,18 @@ def main(argv: list[str] | None = None, *, stdout: TextIO | None = None) -> int: parser.add_argument("--archive-root", type=Path, required=True, help="Archive root to inspect without mutation.") parser.add_argument("--receipt", type=Path, help="Optional worktree-local path for the sanitized JSON receipt.") args = parser.parse_args(argv) - receipt = run_audit(args.archive_root) + archive_root = args.archive_root.resolve() + if args.receipt is not None: + receipt_path = args.receipt.resolve() + try: + receipt_path.relative_to(archive_root) + except ValueError: + pass + else: + parser.error("--receipt must resolve outside --archive-root") + receipt = run_audit(archive_root) if args.receipt is not None: - _write_receipt(args.receipt, receipt) + _write_receipt(args.receipt.resolve(), receipt) print(json.dumps(receipt, indent=2, sort_keys=True), file=stdout) return 0 diff --git a/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json index a4e24afa42..3e829ee93a 100644 --- a/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json +++ b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json @@ -45,6 +45,8 @@ "sqlite_user_version": 24 } }, + "receipt_status": "historical_pre_repair", + "repair_note": "Captured before the producer-cleanliness and blob-snapshot provenance contract. Retained as historical evidence only; do not use as a post-repair rerun receipt.", "schema": "polylogue.chatgpt-lifecycle-anchor-audit.v1", "scope": { "conclusion_limit": "A zero target_pair_count describes only this current parser-and-corpus snapshot. It does not establish the historical pre-fix replay required to reclassify or remove any graph gate.", diff --git a/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md index 2687f6d2a2..6ca4ae5ad2 100644 --- a/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md +++ b/docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor.md @@ -6,21 +6,21 @@ Date: 2026-08-04. Worktree: `feature/fix/chatgpt-anchor-audit`. Scope: record a The prior census was only an untracked `/realm/tmp` report, so its current-corpus conclusion was not independently auditable. This packet is now backed by the committed, reproducible `devtools workspace chatgpt-lifecycle-anchor-audit` command and its sanitized receipt at `docs/audits/2026-08-04-polylogue-uqwd-chatgpt-lifecycle-anchor-receipt.json`. -The historical semantic risk remains real in principle. `tests/unit/sources/test_parsers_chatgpt.py` now carries an end-to-end regression from two mapping orders through the ChatGPT parser, revision projection, relation, and classifier. The direct `ParsedSession` test in `tests/unit/archive/test_session_revision_membership.py` remains only as a classifier-only different-content guard. +The historical semantic risk remains real in principle. `tests/unit/sources/test_parsers_chatgpt.py` now carries an end-to-end regression from two mapping orders through the ChatGPT parser, revision projection, relation, and classifier. The same fixture applies the pre-`b1e01d878` position tiebreak and asserts a `conflict` relation with both raws `ambiguous`, then asserts the current parser produces one accepted and one equivalent raw. The direct `ParsedSession` test in `tests/unit/archive/test_session_revision_membership.py` remains only as a classifier-only different-content guard. The receipt does **not** de-gate `uqwd`, `xselt`, or `818fy`. A zero result from the current parser and corpus is insufficient to establish the bead's required historical replay fixture. No graph state, archive state, blob store, daemon state, or service state was written by this work. ## Reproducible receipt -The command opens `source.db` and `index.db` with SQLite `mode=ro`, reads blobs, then invokes `_parse_one`, `session_revision_projection`, `_relation`, and `classify_membership_revisions`. It selects persisted `raw_session_memberships.logical_source_key` cohorts, reads current `raw_revision_heads` as classifier heads, and does not call a replay or writeback function. The exact SQL, predicate, code revision, schema versions, file sizes and mtimes are in the receipt. +The command opens `source.db` and `index.db` with SQLite `mode=ro`, scans and verifies the blob namespace without writes, then invokes `_parse_one`, `session_revision_projection`, `_relation`, and `classify_membership_revisions`. It selects persisted `raw_session_memberships.logical_source_key` cohorts, reads current `raw_revision_heads` as classifier heads, and does not call a replay or writeback function. A newly generated version 2 receipt will bind the result to the exact producer `HEAD`, working-tree cleanliness and status digest, plus a deterministic full blob namespace snapshot and content-integrity digest whose aggregate identity includes each observed blob digest. Receipt output must resolve outside the archive root. Those fields are not present in the checked-in historical receipt below. -The receipt deliberately contains no raw ids, native ids, source paths, blob hashes, titles, or payload content. It records 7,498 selected quarantined ChatGPT raws, 2,570 persisted cohorts, 15 singleton cohorts, 2,555 multi-candidate cohorts, and 7,483 raws in those multi-candidate cohorts. All 7,498 selected raws were parsed and projected. +The historical receipt checked into this packet predates the version 2 provenance contract. It is retained as historical evidence only and must not be presented as a post-repair rerun. The repaired command deliberately contains no raw ids, native ids, source paths, blob hashes, titles, or payload content. A fresh receipt records the same aggregate fields together with the producer and blob identities needed to make a rerun meaningful. -Across pairwise comparisons the production relation counts were `equal=7,348`, `a_contains_b=126`, `b_contains_a=82`, and `conflict=71`. The target predicate count was zero: no pair had exactly one `generation_lifecycle` event per side, different anchors, equal transcript and attachment content, equal non-anchor lifecycle content, and a conflict relation. This does not imply that the remaining conflicts are harmless or that historical gates can be removed. +The historical receipt's pairwise counts were `equal=7,348`, `a_contains_b=126`, `b_contains_a=82`, and `conflict=71`. Its target predicate count was zero under the pre-repair implementation. That value is not evidence that the repaired predicate has no target pair. The repaired target predicate counts exactly one `generation_lifecycle` event per side, permits other session events, compares normalized lifecycle content after removing the anchor, requires all other normalized event content to match, and requires the production conflict relation. This does not imply that the remaining conflicts are harmless or that historical gates can be removed. ## Regression safeguards -The end-to-end regression constructs two otherwise identical ChatGPT export mappings with different insertion orders, then invokes the real parser, projection, relation, and classifier. It asserts a common anchor, an equal relation, one accepted raw, one equivalent raw, and no ambiguity. It fails against the historical position-based tie-break. +The end-to-end regression constructs two otherwise identical ChatGPT export mappings with different insertion orders, then invokes the real parser, projection, relation, and classifier. Under a test-only mutation that restores the historical position-based tiebreak, it asserts different anchors, a `conflict` relation, and two `ambiguous` raws. With the current parser it asserts a common anchor, an equal relation, one accepted raw, and one equivalent raw. The retained direct classifier guard constructs `ParsedSession` values with different lifecycle `state` content and moved anchors. It asserts conflict and ambiguity with an existing head. Its scope is limited to classifier behavior and it intentionally does not cover parser ordering. @@ -28,14 +28,14 @@ The retained direct classifier guard constructs `ParsedSession` values with diff | Acceptance criterion | Status | Evidence | | --- | --- | --- | -| Run the real classifier and projection path against current quarantined ChatGPT data | Reproducible current-corpus evidence | The committed command and sanitized receipt make the parser, projection, relation, classifier, SQL selection, denominators, and archive provenance reviewable | -| Confirm moved lifecycle anchors still produce conflicts | Historical fixture still required | The end-to-end regression reproduces the mapping-order mechanism, but the current snapshot does not substitute for the required archived pre-fix replay | -| Implement the narrow comparison exception if reproduced | Not applicable | No production code change made | -| Preserve different-content moved-anchor behavior | Satisfied, classifier scope only | The direct `ParsedSession` guard keeps changed lifecycle content conflicting; the parser-to-classifier regression owns ordering behavior | +| Run the real classifier and projection path against current quarantined ChatGPT data | Command ready, receipt pending | The repaired command binds parser, projection, relation, classifier, SQL selection, producer checkout and blob integrity. The checked-in receipt is pre-repair evidence only | +| Confirm moved lifecycle anchors still produce conflicts | Satisfied in historical mutation fixture | The end-to-end regression asserts the real parser-to-classifier conflict and ambiguous result under the pre-fix tiebreak | +| Implement the narrow comparison exception if reproduced | Satisfied | `_matches_target` compares normalized lifecycle content after removing `source_message_provider_id`, includes normalized timing semantics, and rejects unrelated event changes | +| Preserve different-content moved-anchor behavior | Satisfied | The direct `ParsedSession` guard keeps changed lifecycle content conflicting; the parser-to-classifier regression owns ordering behavior | | Reclassify the current blocker edge honestly | Not satisfied | This packet makes no reclassification or de-gating recommendation until the retained historical replay fixture exists | ## Graph disposition and residual uncertainty -No graph-edge conclusion is made here. The current-corpus receipt and regression establish that the active parser is protected against the known ordering bug, but they do not provide a retained pre-`b1e01d878` historical replay fixture. The packet therefore preserves the no-de-gating stance for `uqwd`, `xselt`, and `818fy`. +No graph-edge conclusion is made here. The historical mutation fixture establishes the classifier shape and the active parser remains protected against the known ordering bug, but the packet does not provide a retained pre-fix historical replay of the live corpus. The packet therefore preserves the no-de-gating stance for `uqwd`, `xselt`, and `818fy`. Residual uncertainty is limited to provenance of the archive transition between the 2026-08-03 negative lookup and this 2026-08-04 snapshot, and the absence of an archived pre-fix parser output for the original 10/136 sample. The live durable membership decisions remain unchanged because this lane performed no writeback. diff --git a/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py b/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py index d019fdd0c3..86a9e37482 100644 --- a/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py +++ b/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py @@ -2,14 +2,23 @@ import json import sqlite3 +import subprocess +from dataclasses import replace from pathlib import Path from typing import cast +from unittest.mock import patch import pytest +import devtools.chatgpt_lifecycle_anchor_audit as audit from devtools.chatgpt_lifecycle_anchor_audit import SCHEMA, TARGET_PREDICATE, main, run_audit from devtools.command_catalog import COMMANDS +from polylogue.archive.session_revision_membership import MembershipRevision, _relation from polylogue.core.enums import Origin, Provider +from polylogue.pipeline.ids import session_revision_projection +from polylogue.sources.parsers import chatgpt as chatgpt_parser +from polylogue.sources.parsers.base import ParsedSession, ParsedSessionEvent +from polylogue.sources.revision_backfill import _parse_one as production_parse_one from polylogue.storage.blob_store import BlobStore from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root from polylogue.storage.sqlite.archive_tiers.source_write import write_source_raw_session @@ -96,6 +105,51 @@ def _archive_with_ordered_exports(tmp_path: Path) -> Path: return root +def _historical_parse_one( + provider: Provider, + payload: bytes, + source_path: str, + *, + payload_path: Path | None = None, + archive_root: Path | None = None, + fallback_id_override: str | None = None, +) -> list[ParsedSession]: + """Route through the parser with the pre-fix mapping-position tiebreak.""" + original_extract = chatgpt_parser._extract_generation_timings + + def historical_extract(mapping: dict[str, object]) -> list[object]: + assert isinstance(mapping, dict) + timings = original_extract(mapping) + timed_message_ids: list[str] = [] + for node_id, raw_node in mapping.items(): + if not isinstance(raw_node, dict): + continue + raw_message = raw_node.get("message") + if not isinstance(raw_message, dict): + continue + raw_author = raw_message.get("author") + if not isinstance(raw_author, dict) or raw_author.get("role") not in {"assistant", "tool"}: + continue + metadata = raw_message.get("metadata") + if not isinstance(metadata, dict) or not any( + field in metadata for field in ("reasoning_start_time", "reasoning_end_time", "finished_duration_sec") + ): + continue + timed_message_ids.append(str(raw_message.get("id") or raw_node.get("id") or node_id)) + assert timed_message_ids + return [replace(timing, message_provider_id=timed_message_ids[0]) for timing in timings] + + with patch.object(chatgpt_parser, "_extract_generation_timings", historical_extract): + return production_parse_one( + provider, + payload, + source_path, + payload_path=payload_path, + archive_root=archive_root, + fallback_id_override=fallback_id_override, + ) + + def test_audit_runs_the_parser_to_classifier_route_read_only_and_is_sanitized(tmp_path: Path) -> None: root = _archive_with_ordered_exports(tmp_path) source_before = (root / "source.db").read_bytes() @@ -123,6 +177,19 @@ def test_audit_runs_the_parser_to_classifier_route_read_only_and_is_sanitized(tm "conflict": 0, } assert outcomes["target_pair_count"] == 0 + provenance = cast(dict[str, object], receipt["provenance"]) + assert ( + provenance["producer_git_revision"] + == subprocess.check_output(["git", "rev-parse", "--verify", "HEAD"], text=True).strip() + ) + assert isinstance(provenance["producer_working_tree_clean"], bool) + assert isinstance(provenance["producer_working_tree_status_sha256"], str) + blob_store = cast(dict[str, object], provenance["blob_store"]) + assert blob_store["canonical_blob_count"] == 2 + integrity = cast(dict[str, object], blob_store["integrity"]) + assert integrity["verified_blob_count"] == 2 + assert integrity["hash_mismatch_count"] == 0 + assert integrity["invalid_namespace_entry_count"] == 0 rendered = json.dumps(receipt, sort_keys=True) assert "raw-left" not in rendered assert "raw-right" not in rendered @@ -131,6 +198,85 @@ def test_audit_runs_the_parser_to_classifier_route_read_only_and_is_sanitized(tm assert (root / "index.db").read_bytes() == index_before +def test_audit_matches_historical_moved_anchor_and_current_parser_is_green( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = _archive_with_ordered_exports(tmp_path) + current_parse_one = production_parse_one + monkeypatch.setattr(audit, "_parse_one", _historical_parse_one) + + historical = run_audit(root) + historical_outcomes = cast(dict[str, object], historical["outcomes"]) + assert cast(dict[str, int], historical_outcomes["pair_relation_counts"])["conflict"] == 1 + assert historical_outcomes["target_pair_count"] == 1 + historical_classifier = cast(dict[str, int], historical_outcomes["classifier_cohort_counts"]) + assert historical_classifier["cohorts_with_ambiguous_raw"] == 1 + assert historical_classifier["cohorts_with_accepted_raw"] == 0 + + monkeypatch.setattr(audit, "_parse_one", current_parse_one) + current = run_audit(root) + current_outcomes = cast(dict[str, object], current["outcomes"]) + assert cast(dict[str, int], current_outcomes["pair_relation_counts"])["conflict"] == 0 + assert current_outcomes["target_pair_count"] == 0 + current_classifier = cast(dict[str, int], current_outcomes["classifier_cohort_counts"]) + assert current_classifier["cohorts_with_accepted_raw"] == 1 + assert current_classifier["cohorts_with_equivalent_raw"] == 1 + assert current_classifier["cohorts_with_ambiguous_raw"] == 0 + + +def test_target_normalizes_lifecycle_measurements_and_rejects_other_event_changes(tmp_path: Path) -> None: + _archive_with_ordered_exports(tmp_path) + payloads = [_payload(["u1", "node_a", "node_b"]), _payload(["u1", "node_b", "node_a"])] + left = _historical_parse_one(Provider.CHATGPT, payloads[0], "export.json", fallback_id_override="tie-break-order")[ + 0 + ] + right = _historical_parse_one(Provider.CHATGPT, payloads[1], "export.json", fallback_id_override="tie-break-order")[ + 0 + ] + + left_member = audit._ParsedMember(MembershipRevision("raw-left", session_revision_projection(left)), left) + right_member = audit._ParsedMember(MembershipRevision("raw-right", session_revision_projection(right)), right) + relation = _relation(left_member.revision.projection, right_member.revision.projection) + assert relation == "conflict" + assert audit._matches_target(left_member, right_member, relation) + + measured_event = next(event for event in right.session_events if event.event_type == "generation_lifecycle") + changed_measurement = measured_event.model_copy( + update={ + "timestamp": "2099-01-01T00:00:00Z", + "payload": {**measured_event.payload, "finished_duration_sec": 999}, + } + ) + measurement_changed = right.model_copy( + update={ + "session_events": [ + changed_measurement if event is measured_event else event for event in right.session_events + ] + } + ) + measurement_member = audit._ParsedMember( + MembershipRevision("raw-right", session_revision_projection(measurement_changed)), measurement_changed + ) + measurement_relation = _relation(left_member.revision.projection, measurement_member.revision.projection) + assert measurement_relation == "conflict" + assert audit._matches_target(left_member, measurement_member, measurement_relation) + + unrelated_changed = right.model_copy( + update={ + "session_events": [ + *right.session_events, + ParsedSessionEvent(event_type="unrelated_observation", payload={"value": "changed"}), + ] + } + ) + unrelated_member = audit._ParsedMember( + MembershipRevision("raw-right", session_revision_projection(unrelated_changed)), unrelated_changed + ) + unrelated_relation = _relation(left_member.revision.projection, unrelated_member.revision.projection) + assert unrelated_relation == "conflict" + assert not audit._matches_target(left_member, unrelated_member, unrelated_relation) + + def test_audit_receipt_is_deterministic_and_cli_registers_the_command( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -147,6 +293,38 @@ def test_audit_receipt_is_deterministic_and_cli_registers_the_command( assert command.module == "devtools.chatgpt_lifecycle_anchor_audit" +def test_blob_integrity_identity_includes_observed_content_and_receipt_stays_outside_archive( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + root = _archive_with_ordered_exports(tmp_path) + blob_store = BlobStore(root / "blob") + blob_hash, _ = blob_store.write_from_bytes(b"extra blob") + blob_path = blob_store.blob_path(blob_hash) + original = blob_path.read_bytes() + healthy = run_audit(root) + + blob_path.write_bytes(b"x" * len(original)) + first_corrupt = run_audit(root) + blob_path.write_bytes(b"y" * len(original)) + second_corrupt = run_audit(root) + healthy_blob = cast(dict[str, object], cast(dict[str, object], healthy["provenance"])["blob_store"]) + healthy_integrity = cast(dict[str, object], healthy_blob["integrity"]) + first_blob = cast(dict[str, object], cast(dict[str, object], first_corrupt["provenance"])["blob_store"]) + second_blob = cast(dict[str, object], cast(dict[str, object], second_corrupt["provenance"])["blob_store"]) + first_integrity = cast(dict[str, object], first_blob["integrity"]) + second_integrity = cast(dict[str, object], second_blob["integrity"]) + assert first_blob["snapshot_sha256"] == second_blob["snapshot_sha256"] + assert first_integrity["hash_mismatch_count"] == second_integrity["hash_mismatch_count"] == 1 + assert first_integrity["integrity_sha256"] != second_integrity["integrity_sha256"] + assert first_integrity["integrity_sha256"] != healthy_integrity["integrity_sha256"] + assert blob_hash not in json.dumps(second_corrupt, sort_keys=True) + + with pytest.raises(SystemExit): + main(["--archive-root", str(root), "--receipt", str(root / "receipt.json")]) + capsys.readouterr() + assert not (root / "receipt.json").exists() + + def test_audit_requires_a_real_sqlite_archive(tmp_path: Path) -> None: root = tmp_path / "archive" root.mkdir() diff --git a/tests/unit/sources/test_parsers_chatgpt.py b/tests/unit/sources/test_parsers_chatgpt.py index 44514c5cd1..b5ee8bb39a 100644 --- a/tests/unit/sources/test_parsers_chatgpt.py +++ b/tests/unit/sources/test_parsers_chatgpt.py @@ -5,8 +5,10 @@ import json import sqlite3 from collections.abc import Callable, Mapping, Sequence +from dataclasses import replace from pathlib import Path from typing import Any, TypeAlias +from unittest.mock import patch import pytest @@ -14,6 +16,7 @@ from polylogue.core.enums import BlockType, MaterialOrigin from polylogue.pipeline.ids import session_revision_projection from polylogue.scenarios import CorpusSpec +from polylogue.sources.parsers import chatgpt as chatgpt_parser from polylogue.sources.parsers.base import ParsedContentBlock, ParsedSession from polylogue.sources.parsers.chatgpt import ( SHARED_CONVERSATION_INDEX_INGEST_FLAG, @@ -1597,6 +1600,47 @@ def parsed(order: list[dict[str, Any]]) -> ParsedSession: assert result.equivalent_raw_ids == ("raw-right",) assert result.ambiguous_raw_ids == () + original_extract = chatgpt_parser._extract_generation_timings + + def historical_extract(mapping: Mapping[str, object]) -> list[Any]: + timings = original_extract(mapping) + timed_message_ids: list[str] = [] + for node_id, raw_node in mapping.items(): + if not isinstance(raw_node, Mapping): + continue + raw_message = raw_node.get("message") + if not isinstance(raw_message, Mapping): + continue + raw_author = raw_message.get("author") + if not isinstance(raw_author, Mapping) or raw_author.get("role") not in {"assistant", "tool"}: + continue + metadata = raw_message.get("metadata") + if not isinstance(metadata, Mapping) or not any( + field in metadata for field in ("reasoning_start_time", "reasoning_end_time", "finished_duration_sec") + ): + continue + timed_message_ids.append(str(raw_message.get("id") or raw_node.get("id") or node_id)) + assert timed_message_ids + return [replace(timing, message_provider_id=timed_message_ids[0]) for timing in timings] + + def historically_parsed(order: list[dict[str, Any]]) -> ParsedSession: + payload = {"id": "tie-break-order", "mapping": {node["id"]: node for node in order}, "current_node": "node_b"} + with patch.object(chatgpt_parser, "_extract_generation_timings", historical_extract): + return chatgpt_parse(payload, "fallback-id") + + historical_left = historically_parsed([user, node_a, node_b]) + historical_right = historically_parsed([user, node_b, node_a]) + historical_revisions = [ + MembershipRevision(raw_id, session_revision_projection(session)) + for raw_id, session in (("raw-left", historical_left), ("raw-right", historical_right)) + ] + assert historical_left.session_events[0].source_message_provider_id == "node_a" + assert historical_right.session_events[0].source_message_provider_id == "node_b" + assert _relation(historical_revisions[0].projection, historical_revisions[1].projection) == "conflict" + historical_result = classify_membership_revisions(historical_revisions, existing_accepted_raw_id="raw-left") + assert historical_result.accepted_raw_ids == () + assert historical_result.ambiguous_raw_ids == ("raw-left", "raw-right") + # --------------------------------------------------------------------------- # #1744 — non-`parts` content is preserved (code interpreter, execution output) From 11680a4ff6aa22dcfd5ca270294375a87e4baea5 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 4 Aug 2026 23:53:11 +0200 Subject: [PATCH 5/6] fix(audit): validate provenance before candidate reads Problem: the lifecycle-anchor audit opened archive candidate data before validating the producer checkout, and its target predicate ignored attachment identity conflicts when attachment contents were equal.\n\nWhat changed: validate git provenance before constructing or opening candidate archive inputs, compare attachment identities in target matching, and add ordering and red-twin regressions.\n\nCompatibility/migration: audit-only behavior change; no archive schema or live archive writes. --- devtools/chatgpt_lifecycle_anchor_audit.py | 6 +- .../test_chatgpt_lifecycle_anchor_audit.py | 57 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/devtools/chatgpt_lifecycle_anchor_audit.py b/devtools/chatgpt_lifecycle_anchor_audit.py index f67664cad7..1e5eb48e9e 100644 --- a/devtools/chatgpt_lifecycle_anchor_audit.py +++ b/devtools/chatgpt_lifecycle_anchor_audit.py @@ -35,7 +35,8 @@ TARGET_PREDICATE = ( "A pair in one persisted logical_source_key cohort where each parsed session has exactly one " "generation_lifecycle event (other session events are allowed), their source_message_provider_id " - "anchors differ, message_contents and attachment_contents are equal, generation_lifecycle event " + "anchors differ, message_contents, attachment_identities, and attachment_contents are equal, " + "generation_lifecycle event " "content hashes after removing source_message_provider_id are equal, all normalized event content " "is equal after that same lifecycle-only exception, and the production _relation is conflict." ) @@ -205,6 +206,7 @@ def _matches_target(left: _ParsedMember, right: _ParsedMember, relation: _Relati return ( left_event.source_message_provider_id != right_event.source_message_provider_id and left_projection.message_contents == right_projection.message_contents + and left_projection.attachment_identities == right_projection.attachment_identities and left_projection.attachment_contents == right_projection.attachment_contents and _session_event_content_signatures(left.session) == _session_event_content_signatures(right.session) and _anchor_independent_event_content(left_event) == _anchor_independent_event_content(right_event) @@ -246,6 +248,7 @@ def _cohorts(rows: Iterable[_RawMember]) -> dict[str, list[_RawMember]]: def run_audit(archive_root: Path) -> dict[str, object]: """Run the full current-corpus census without opening an archive writer.""" + producer = _git_provenance() source_db = archive_root / "source.db" index_db = archive_root / "index.db" blob_store = BlobStore(archive_root / "blob") @@ -267,7 +270,6 @@ def run_audit(archive_root: Path) -> dict[str, object]: parsed_raw_count = 0 heads = _load_existing_heads(index_conn) blob_snapshot = _blob_store_snapshot(blob_store) - producer = _git_provenance() for logical_source_key in sorted(cohorts): revisions = [ _parse_member(member, blob_store, archive_root) diff --git a/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py b/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py index 86a9e37482..a7f356bdca 100644 --- a/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py +++ b/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py @@ -198,6 +198,37 @@ def test_audit_runs_the_parser_to_classifier_route_read_only_and_is_sanitized(tm assert (root / "index.db").read_bytes() == index_before +def test_audit_validates_git_before_opening_candidate_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = _archive_with_ordered_exports(tmp_path) + events: list[str] = [] + original_connect_read_only = audit._connect_read_only + original_blob_snapshot = audit._blob_store_snapshot + + def tracked_git_provenance() -> dict[str, object]: + events.append("git") + return { + "git_revision": "test-revision", + "working_tree_clean": True, + "working_tree_status_sha256": "test-status", + } + + def tracked_connect_read_only(path: Path) -> sqlite3.Connection: + events.append(f"open:{path.name}") + return original_connect_read_only(path) + + def tracked_blob_snapshot(blob_store: BlobStore) -> dict[str, object]: + events.append("scan:blob") + return original_blob_snapshot(blob_store) + + monkeypatch.setattr(audit, "_git_provenance", tracked_git_provenance) + monkeypatch.setattr(audit, "_connect_read_only", tracked_connect_read_only) + monkeypatch.setattr(audit, "_blob_store_snapshot", tracked_blob_snapshot) + + run_audit(root) + + assert events == ["git", "open:source.db", "open:index.db", "scan:blob"] + + def test_audit_matches_historical_moved_anchor_and_current_parser_is_green( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -277,6 +308,32 @@ def test_target_normalizes_lifecycle_measurements_and_rejects_other_event_change assert not audit._matches_target(left_member, unrelated_member, unrelated_relation) +def test_target_rejects_red_twin_with_equal_attachment_contents_and_different_identities(tmp_path: Path) -> None: + _archive_with_ordered_exports(tmp_path) + payloads = [_payload(["u1", "node_a", "node_b"]), _payload(["u1", "node_b", "node_a"])] + left = _historical_parse_one(Provider.CHATGPT, payloads[0], "export.json", fallback_id_override="tie-break-order")[ + 0 + ] + right = _historical_parse_one(Provider.CHATGPT, payloads[1], "export.json", fallback_id_override="tie-break-order")[ + 0 + ] + left_member = audit._ParsedMember(MembershipRevision("raw-left", session_revision_projection(left)), left) + right_projection = session_revision_projection(right) + red_twin_projection = replace(right_projection, attachment_identities=frozenset({b"red-twin-identity"})) + red_twin_member = audit._ParsedMember(MembershipRevision("raw-right", red_twin_projection), right) + + assert ( + left_member.revision.projection.attachment_contents == red_twin_member.revision.projection.attachment_contents + ) + assert ( + left_member.revision.projection.attachment_identities + != red_twin_member.revision.projection.attachment_identities + ) + relation = _relation(left_member.revision.projection, red_twin_member.revision.projection) + assert relation == "conflict" + assert not audit._matches_target(left_member, red_twin_member, relation) + + def test_audit_receipt_is_deterministic_and_cli_registers_the_command( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: From baf7759b62090afa080270002339fe61b9492bd8 Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 5 Aug 2026 00:16:05 +0200 Subject: [PATCH 6/6] fix(audit): honor JSON dispatch and prove attachment conflicts Problem: the shared devtools dispatcher appends --json to every command, but the ChatGPT lifecycle audit rejected that flag. Its attachment red-twin regression also fabricated a projection after parsing an attachment-free payload, so equal attachment contents were only vacuously proven. What changed: accept the shared JSON flag, route ChatGPT attachment metadata through the shared parser normalizer, and replace the fabricated red-twin with parser-driven payloads covering equal known bytes with distinct identities and an id-less unresolved reference. Add a real devtools dispatch regression and parser coverage. Compatibility/migration: the audit already emits JSON by default, so --json is additive and preserves the existing output shape. Ref #3771 Co-Authored-By: Codex --- devtools/chatgpt_lifecycle_anchor_audit.py | 5 + polylogue/sources/parsers/chatgpt.py | 25 +--- .../test_chatgpt_lifecycle_anchor_audit.py | 128 ++++++++++++++---- tests/unit/sources/test_parsers_chatgpt.py | 30 ++++ 4 files changed, 142 insertions(+), 46 deletions(-) diff --git a/devtools/chatgpt_lifecycle_anchor_audit.py b/devtools/chatgpt_lifecycle_anchor_audit.py index 1e5eb48e9e..afdae3e6ab 100644 --- a/devtools/chatgpt_lifecycle_anchor_audit.py +++ b/devtools/chatgpt_lifecycle_anchor_audit.py @@ -349,6 +349,11 @@ def main(argv: list[str] | None = None, *, stdout: TextIO | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--archive-root", type=Path, required=True, help="Archive root to inspect without mutation.") parser.add_argument("--receipt", type=Path, help="Optional worktree-local path for the sanitized JSON receipt.") + parser.add_argument( + "--json", + action="store_true", + help="Emit the machine-readable JSON audit report (the default output format).", + ) args = parser.parse_args(argv) archive_root = args.archive_root.resolve() if args.receipt is not None: diff --git a/polylogue/sources/parsers/chatgpt.py b/polylogue/sources/parsers/chatgpt.py index ebc900512d..ff9bf10b3a 100644 --- a/polylogue/sources/parsers/chatgpt.py +++ b/polylogue/sources/parsers/chatgpt.py @@ -22,6 +22,7 @@ ParsedSession, ParsedSessionEvent, ParsedWebConstruct, + attachment_from_meta, human_authored_override, ) @@ -638,27 +639,9 @@ def extract_messages_from_mapping( msg_attachments = msg_metadata.get("attachments") or [] if isinstance(msg_attachments, list): for attach in msg_attachments: - if isinstance(attach, dict) and attach.get("id"): - # #1252: ChatGPT attachments arrive through the OAuth- - # authenticated export; the only native identifier is - # `id`. file_id is recorded when the export carries one - # (some private deployments surface it). - file_id_raw = attach.get("file_id") or attach.get("fileId") - attachments.append( - ParsedAttachment( - provider_attachment_id=str(attach["id"]), - message_provider_id=str(msg_id), - name=str(attach["name"]) if attach.get("name") else None, - mime_type=str(attach["mime_type"]) if attach.get("mime_type") else None, - size_bytes=int(attach["size"]) - if isinstance(attach.get("size"), (int, float)) - else None, - provider_file_id=str(file_id_raw) - if isinstance(file_id_raw, str) and file_id_raw - else None, - upload_origin="oauth", - ) - ) + attachment = attachment_from_meta(attach, str(msg_id)) + if attachment is not None: + attachments.append(attachment) # Assistant-generated downloadable files (#sandbox links). Code # Interpreter deliverables surface only as `sandbox:/mnt/data/...` diff --git a/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py b/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py index a7f356bdca..d3c13d1df9 100644 --- a/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py +++ b/tests/unit/devtools/test_chatgpt_lifecycle_anchor_audit.py @@ -10,6 +10,7 @@ import pytest +import devtools.__main__ as devtools_main import devtools.chatgpt_lifecycle_anchor_audit as audit from devtools.chatgpt_lifecycle_anchor_audit import SCHEMA, TARGET_PREDICATE, main, run_audit from devtools.command_catalog import COMMANDS @@ -24,7 +25,18 @@ from polylogue.storage.sqlite.archive_tiers.source_write import write_source_raw_session -def _node(node_id: str, role: str, text: str, parent: str | None, children: list[str]) -> dict[str, object]: +def _node( + node_id: str, + role: str, + text: str, + parent: str | None, + children: list[str], + *, + attachment: dict[str, object] | None = None, +) -> dict[str, object]: + metadata: dict[str, object] = {"finished_duration_sec": 5} if role == "assistant" else {} + if attachment is not None: + metadata["attachments"] = [attachment] return { "id": node_id, "parent": parent, @@ -33,17 +45,17 @@ def _node(node_id: str, role: str, text: str, parent: str | None, children: list "id": node_id, "author": {"role": role}, "content": {"content_type": "text", "parts": [text]}, - "metadata": {"finished_duration_sec": 5} if role == "assistant" else {}, + "metadata": metadata, "end_turn": role == "assistant", }, } -def _payload(order: list[str]) -> bytes: +def _payload(order: list[str], *, attachment: dict[str, object] | None = None) -> bytes: nodes = { "u1": _node("u1", "user", "do the work", None, ["node_a"]), "node_a": _node("node_a", "assistant", "first draft", "u1", ["node_b"]), - "node_b": _node("node_b", "assistant", "final draft", "node_a", []), + "node_b": _node("node_b", "assistant", "final draft", "node_a", [], attachment=attachment), } return json.dumps( {"id": "tie-break-order", "mapping": {node_id: nodes[node_id] for node_id in order}, "current_node": "node_b"}, @@ -308,30 +320,72 @@ def test_target_normalizes_lifecycle_measurements_and_rejects_other_event_change assert not audit._matches_target(left_member, unrelated_member, unrelated_relation) -def test_target_rejects_red_twin_with_equal_attachment_contents_and_different_identities(tmp_path: Path) -> None: - _archive_with_ordered_exports(tmp_path) - payloads = [_payload(["u1", "node_a", "node_b"]), _payload(["u1", "node_b", "node_a"])] - left = _historical_parse_one(Provider.CHATGPT, payloads[0], "export.json", fallback_id_override="tie-break-order")[ - 0 - ] - right = _historical_parse_one(Provider.CHATGPT, payloads[1], "export.json", fallback_id_override="tie-break-order")[ - 0 - ] +def test_target_rejects_parsed_attachment_red_twin_and_unknown_reference() -> None: + attachment_bytes = "same known attachment bytes" + left = _historical_parse_one( + Provider.CHATGPT, + _payload( + ["u1", "node_a", "node_b"], + attachment={ + "id": "attachment-left", + "name": "report.txt", + "mime_type": "text/plain", + "extracted_content": attachment_bytes, + }, + ), + "export.json", + fallback_id_override="tie-break-order", + )[0] + right = _historical_parse_one( + Provider.CHATGPT, + _payload( + ["u1", "node_b", "node_a"], + attachment={ + "id": "attachment-right", + "name": "renamed-report.txt", + "mime_type": "text/plain", + "extracted_content": attachment_bytes, + }, + ), + "export.json", + fallback_id_override="tie-break-order", + )[0] left_member = audit._ParsedMember(MembershipRevision("raw-left", session_revision_projection(left)), left) - right_projection = session_revision_projection(right) - red_twin_projection = replace(right_projection, attachment_identities=frozenset({b"red-twin-identity"})) - red_twin_member = audit._ParsedMember(MembershipRevision("raw-right", red_twin_projection), right) + right_member = audit._ParsedMember(MembershipRevision("raw-right", session_revision_projection(right)), right) - assert ( - left_member.revision.projection.attachment_contents == red_twin_member.revision.projection.attachment_contents - ) - assert ( - left_member.revision.projection.attachment_identities - != red_twin_member.revision.projection.attachment_identities - ) - relation = _relation(left_member.revision.projection, red_twin_member.revision.projection) + assert [attachment.inline_bytes for attachment in left.attachments] == [attachment_bytes.encode()] + assert [attachment.inline_bytes for attachment in right.attachments] == [attachment_bytes.encode()] + left_projection = left_member.revision.projection + right_projection = right_member.revision.projection + assert left_projection.attachment_identities != right_projection.attachment_identities + assert {content for _identity, content in left_projection.attachment_contents} == { + content for _identity, content in right_projection.attachment_contents + } + relation = _relation(left_projection, right_projection) assert relation == "conflict" - assert not audit._matches_target(left_member, red_twin_member, relation) + assert not audit._matches_target(left_member, right_member, relation) + + unknown = _historical_parse_one( + Provider.CHATGPT, + _payload( + ["u1", "node_a", "node_b"], + attachment={"name": "report.txt", "mime_type": "text/plain"}, + ), + "export.json", + fallback_id_override="tie-break-order", + )[0] + unknown_member = audit._ParsedMember( + MembershipRevision("raw-unknown", session_revision_projection(unknown)), unknown + ) + unknown_projection = unknown_member.revision.projection + assert len(unknown.attachments) == 1 + assert unknown.attachments[0].provider_attachment_id.startswith("att-") + assert unknown.attachments[0].inline_bytes is None + assert unknown_projection.attachment_identities == left_projection.attachment_identities + assert unknown_projection.attachment_contents == frozenset() + unknown_relation = _relation(left_projection, unknown_projection) + assert unknown_relation in {"a_contains_b", "b_contains_a"} + assert not audit._matches_target(left_member, unknown_member, unknown_relation) def test_audit_receipt_is_deterministic_and_cli_registers_the_command( @@ -350,6 +404,30 @@ def test_audit_receipt_is_deterministic_and_cli_registers_the_command( assert command.module == "devtools.chatgpt_lifecycle_anchor_audit" +def test_audit_accepts_shared_json_flag_through_real_devtools_dispatch( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + root = _archive_with_ordered_exports(tmp_path) + monkeypatch.setenv("POLYLOGUE_TASK_HISTORY_DISABLE", "1") + + assert ( + devtools_main.main( + [ + "--json", + "workspace", + "chatgpt-lifecycle-anchor-audit", + "--archive-root", + str(root), + ] + ) + == 0 + ) + + receipt = json.loads(capsys.readouterr().out) + assert receipt["schema"] == SCHEMA + assert receipt["provenance"]["archive_access"].startswith("SQLite source.db") + + def test_blob_integrity_identity_includes_observed_content_and_receipt_stays_outside_archive( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/unit/sources/test_parsers_chatgpt.py b/tests/unit/sources/test_parsers_chatgpt.py index b5ee8bb39a..e9c5680639 100644 --- a/tests/unit/sources/test_parsers_chatgpt.py +++ b/tests/unit/sources/test_parsers_chatgpt.py @@ -651,6 +651,36 @@ def test_chatgpt_block_content_type_routes_to_session_events() -> None: assert event.payload == {"block_index": 0, "content_type": "reasoning_recap"} +def test_chatgpt_attachment_payload_preserves_known_bytes_and_unknown_reference() -> None: + mapping = { + "node1": make_chatgpt_node("msg1", "assistant", ["answer"]), + } + node = mapping["node1"] + assert isinstance(node, dict) + message = node["message"] + assert isinstance(message, dict) + message["metadata"] = { + "attachments": [ + { + "id": "known-attachment", + "name": "report.txt", + "mime_type": "text/plain", + "extracted_content": "known bytes", + }, + {"name": "unknown-report.txt", "mime_type": "text/plain"}, + ] + } + + session = chatgpt_parse({"id": "attachment-fixture", "mapping": mapping}, "fallback") + + assert len(session.attachments) == 2 + known, unknown = session.attachments + assert known.provider_attachment_id == "known-attachment" + assert known.inline_bytes == b"known bytes" + assert unknown.provider_attachment_id.startswith("att-") + assert unknown.inline_bytes is None + + @pytest.mark.parametrize("metadata,expected_type,desc", CHATGPT_METADATA_CASES) def test_chatgpt_metadata_extraction(metadata: object, expected_type: str | None, desc: str) -> None: """Test metadata extraction from message metadata field.