From abb9de9ff17ee343a37a353cefa199b127d65630 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 01:46:32 +0900 Subject: [PATCH 1/4] security(deps): require patched PyJWT releases --- pyproject.toml | 4 +-- tests/test_pyjwt_advisory_floor.py | 41 ++++++++++++++++++++++++++++++ uv.lock | 10 ++++---- 3 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 tests/test_pyjwt_advisory_floor.py diff --git a/pyproject.toml b/pyproject.toml index 7744aef87..790f10407 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ build-backend = "setuptools.build_meta" dev = [ "psycopg2-binary>=2.9.12", "coverage>=7.6", - "pyjwt[crypto]>=2.8.0", + "pyjwt[crypto]>=2.14.0", "pytest>=8.0", "httpx>=0.27.0", # Closed-world SHACL validation of docs/ontology/lineageweave-kg.ttl @@ -48,7 +48,7 @@ backend = [ "fastapi>=0.141.1", "uvicorn[standard]>=0.30.0", "asyncpg>=0.29.0", - "pyjwt[crypto]>=2.8.0", + "pyjwt[crypto]>=2.14.0", # Speaks RESP; works against Valkey (a Redis-protocol-compatible fork) # as well as real Redis. Used for the post-activity event stream. "redis>=5.0.1", diff --git a/tests/test_pyjwt_advisory_floor.py b/tests/test_pyjwt_advisory_floor.py new file mode 100644 index 000000000..3be3e85c2 --- /dev/null +++ b/tests/test_pyjwt_advisory_floor.py @@ -0,0 +1,41 @@ +"""Keep every install surface on a PyJWT release outside affected advisories.""" + +from __future__ import annotations + +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PATCHED_VERSION = (2, 14, 0) + + +def _version_tuple(version: str) -> tuple[int, int, int]: + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", version) + assert match is not None, f"unexpected PyJWT version syntax: {version!r}" + return tuple(int(part) for part in match.groups()) + + +def test_dev_and_backend_require_the_patched_pyjwt_release() -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text()) + extras = project["project"]["optional-dependencies"] + + for extra_name in ("dev", "backend"): + requirements = [ + requirement + for requirement in extras[extra_name] + if requirement.lower().startswith("pyjwt[crypto]") + ] + assert requirements == ["pyjwt[crypto]>=2.14.0"] + + +def test_lockfile_contains_only_patched_pyjwt_releases() -> None: + lock = tomllib.loads((ROOT / "uv.lock").read_text()) + versions = [ + package["version"] + for package in lock["package"] + if package["name"].lower() == "pyjwt" + ] + + assert versions, "uv.lock must contain PyJWT" + assert all(_version_tuple(version) >= PATCHED_VERSION for version in versions) diff --git a/uv.lock b/uv.lock index f94e79cf4..874b7dd4a 100644 --- a/uv.lock +++ b/uv.lock @@ -735,8 +735,8 @@ requires-dist = [ { name = "opentelemetry-sdk", specifier = ">=1.30.0" }, { name = "pillow", specifier = ">=12.3.0" }, { name = "psycopg2-binary", marker = "extra == 'dev'", specifier = ">=2.9.12" }, - { name = "pyjwt", extras = ["crypto"], marker = "extra == 'backend'", specifier = ">=2.8.0" }, - { name = "pyjwt", extras = ["crypto"], marker = "extra == 'dev'", specifier = ">=2.8.0" }, + { name = "pyjwt", extras = ["crypto"], marker = "extra == 'backend'", specifier = ">=2.14.0" }, + { name = "pyjwt", extras = ["crypto"], marker = "extra == 'dev'", specifier = ">=2.14.0" }, { name = "pyshacl", marker = "extra == 'dev'", specifier = ">=0.26.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "rankweave", git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6" }, @@ -1218,11 +1218,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies] From db96ff11c977a92180b5480884bc361a4be5cf75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 12:43:21 +0900 Subject: [PATCH 2/4] fix(security): raise PyJWT and urllib3 advisory floors --- CHANGELOG.md | 4 ++ ...neageweave-dependency-security-20261001.md | 33 ++++++++++++++ docs/product-technical-gap-baseline.md | 24 ++++++++++ pyproject.toml | 7 ++- tests/test_pyjwt_advisory_floor.py | 44 ++++++++++++++++--- uv.lock | 12 ++--- 6 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 docs/doctoring/lineageweave-dependency-security-20261001.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2724eb8..024294cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -268,6 +268,10 @@ All notable changes to this project are documented here. Format follows ### Fixed +- Security dependency floors now require PyJWT 2.15.1 and urllib3 2.8.0, + regenerate the exact `uv.lock`, and test both source declarations and lock + selections against the CVEs reported on LineageWeave#1138. + - Full-corpus Event Lineage rebuilds now count candidate pairs before provider work and omit the optional LLM channel above the 5,000-pair ADR budget, preventing millions of synchronous orchestrator calls while retaining one diff --git a/docs/doctoring/lineageweave-dependency-security-20261001.md b/docs/doctoring/lineageweave-dependency-security-20261001.md new file mode 100644 index 000000000..cad5cb13c --- /dev/null +++ b/docs/doctoring/lineageweave-dependency-security-20261001.md @@ -0,0 +1,33 @@ +# LineageWeave dependency Security RCA — 2026-10-01 + +Status: Proposed on `ContextualWisdomLab/LineageWeave#1137`; protected +integration, exact-current-head Checks, and independent approval remain +mandatory. + +## Exact failure evidence + +`ContextualWisdomLab/LineageWeave#1138@e98a68ed99566f6c145b84c3ec816216dd720ebb` +failed Security Scan run `36811272599`, Trivy job `110206798976`. The exact +SARIF gate reported PyJWT CVE-2026-102265 through CVE-2026-102274 plus +CVE-2026-101917 and CVE-2026-101918 against `uv.lock`'s PyJWT 2.13.0. It also +reported urllib3 CVE-2026-97687, CVE-2026-97688, and CVE-2026-97689 against +urllib3 2.7.0. + +The Voice-derivation code changed by #1138 does not own dependency policy. +`ContextualWisdomLab/LineageWeave#1137` is the existing canonical dependency +owner and already selects PyJWT 2.15.1. The remaining root cause was that its +source floor still admitted earlier releases and urllib3 remained an unbounded +transitive dependency. + +## RED → GREEN repair + +The owner contract first failed three assertions: both PyJWT extras still +declared `>=2.14.0`, no direct urllib3 floor existed, and the lock selected +urllib3 2.7.0. The repair requires PyJWT 2.15.1 on both install surfaces, +declares urllib3 2.8.0 once in core dependencies, and regenerates `uv.lock` +with the repository's `uv` resolver. Only urllib3 moves in the resolved package +set; PyJWT was already resolved to 2.15.1. + +Consumers must ordinary-merge the accepted owner lineage. A terminal Security +gate on the owner and every consumer is required; skipped, queued, pending, or +predecessor results are not acceptance. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b5d31877b..a7716acda 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -834,6 +834,30 @@ of leverage; open connector PRs there when the defect is upstream: - Until the protected deployment and exact URL checks succeed, the public ontology endpoint remains unavailable and must not be represented as live. +## 2026-10-01 dependency advisory floor + +**Status:** Proposed on `ContextualWisdomLab/LineageWeave#1137`; merge and +release remain HOLD pending exact-current-head hosted evidence and independent +approval. + +**Context Map / owner.** LineageWeave owns its application dependency source +and generated `uv.lock`. PyPI artifacts and the vulnerability database are +upstream evidence; feature branches consume the owner through ordinary Git +history instead of carrying private lock-file workarounds. + +**Gap / RCA.** Exact consumer head +`ContextualWisdomLab/LineageWeave#1138@e98a68ed99566f6c145b84c3ec816216dd720ebb` +failed Security Scan `36811272599`, job `110206798976`: PyJWT 2.13.0 carried +twelve reported CVEs and urllib3 2.7.0 carried CVE-2026-97687, +CVE-2026-97688, and CVE-2026-97689. Existing owner #1137 selected PyJWT +2.15.1 but its source floor remained 2.14.0 and urllib3 was only transitive. + +**RED → GREEN / acceptance.** The owner test failed on both stale source +contracts and the urllib3 lock. The repair requires PyJWT 2.15.1, adds a direct +urllib3 2.8.0 floor, and regenerates the lock without moving an unrelated +resolved package. Accept only after owner and consumer exact heads complete +Security, tests, SAST, non-skipped CodeQL, and qualifying independent review. + ## 9. Evidence boundaries - Never add a real record, title, name, identifier, screenshot, log, benchmark diff --git a/pyproject.toml b/pyproject.toml index 790f10407..5084138d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,9 @@ dependencies = [ # Explicit CA bundle for http_client HTTPS posts -- some interpreter # distributions don't reliably inherit the OS trust store. "certifi>=2024.0.0", + # Explicit security floor for transitive HTTP clients. Keep this aligned + # with the repository advisory contract and generated uv lock. + "urllib3>=2.8.0", "cryptography>=42.0", # The standard Python RDF/OWL library -- parses and validates # docs/ontology/lineageweave-kg.ttl and the standards-complete PROV-O @@ -35,7 +38,7 @@ build-backend = "setuptools.build_meta" dev = [ "psycopg2-binary>=2.9.12", "coverage>=7.6", - "pyjwt[crypto]>=2.14.0", + "pyjwt[crypto]>=2.15.1", "pytest>=8.0", "httpx>=0.27.0", # Closed-world SHACL validation of docs/ontology/lineageweave-kg.ttl @@ -48,7 +51,7 @@ backend = [ "fastapi>=0.141.1", "uvicorn[standard]>=0.30.0", "asyncpg>=0.29.0", - "pyjwt[crypto]>=2.14.0", + "pyjwt[crypto]>=2.15.1", # Speaks RESP; works against Valkey (a Redis-protocol-compatible fork) # as well as real Redis. Used for the post-activity event stream. "redis>=5.0.1", diff --git a/tests/test_pyjwt_advisory_floor.py b/tests/test_pyjwt_advisory_floor.py index 3be3e85c2..2e4269902 100644 --- a/tests/test_pyjwt_advisory_floor.py +++ b/tests/test_pyjwt_advisory_floor.py @@ -1,4 +1,4 @@ -"""Keep every install surface on a PyJWT release outside affected advisories.""" +"""Keep dependency locks outside the PyJWT and urllib3 advisory ranges.""" from __future__ import annotations @@ -7,12 +7,13 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -PATCHED_VERSION = (2, 14, 0) +PYJWT_PATCHED_VERSION = (2, 15, 1) +URLLIB3_PATCHED_VERSION = (2, 8, 0) -def _version_tuple(version: str) -> tuple[int, int, int]: +def _parse_version_tuple(version: str) -> tuple[int, int, int]: match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", version) - assert match is not None, f"unexpected PyJWT version syntax: {version!r}" + assert match is not None, f"unexpected dependency version syntax: {version!r}" return tuple(int(part) for part in match.groups()) @@ -26,7 +27,7 @@ def test_dev_and_backend_require_the_patched_pyjwt_release() -> None: for requirement in extras[extra_name] if requirement.lower().startswith("pyjwt[crypto]") ] - assert requirements == ["pyjwt[crypto]>=2.14.0"] + assert requirements == ["pyjwt[crypto]>=2.15.1"] def test_lockfile_contains_only_patched_pyjwt_releases() -> None: @@ -38,4 +39,35 @@ def test_lockfile_contains_only_patched_pyjwt_releases() -> None: ] assert versions, "uv.lock must contain PyJWT" - assert all(_version_tuple(version) >= PATCHED_VERSION for version in versions) + assert all( + _parse_version_tuple(version) >= PYJWT_PATCHED_VERSION + for version in versions + ) + + +def test_project_requires_the_patched_urllib3_release() -> None: + """Make the urllib3 advisory floor explicit instead of transitive.""" + project = tomllib.loads((ROOT / "pyproject.toml").read_text()) + requirements = [ + requirement + for requirement in project["project"]["dependencies"] + if requirement.lower().startswith("urllib3") + ] + + assert requirements == ["urllib3>=2.8.0"] + + +def test_lockfile_contains_only_patched_urllib3_releases() -> None: + """Reject urllib3 versions affected by the exact-head Trivy findings.""" + lock = tomllib.loads((ROOT / "uv.lock").read_text()) + versions = [ + package["version"] + for package in lock["package"] + if package["name"].lower() == "urllib3" + ] + + assert versions, "uv.lock must contain urllib3" + assert all( + _parse_version_tuple(version) >= URLLIB3_PATCHED_VERSION + for version in versions + ) diff --git a/uv.lock b/uv.lock index 874b7dd4a..23e49a4b7 100644 --- a/uv.lock +++ b/uv.lock @@ -697,6 +697,7 @@ dependencies = [ { name = "rankweave" }, { name = "rdflib" }, { name = "threadweave" }, + { name = "urllib3" }, ] [package.optional-dependencies] @@ -735,14 +736,15 @@ requires-dist = [ { name = "opentelemetry-sdk", specifier = ">=1.30.0" }, { name = "pillow", specifier = ">=12.3.0" }, { name = "psycopg2-binary", marker = "extra == 'dev'", specifier = ">=2.9.12" }, - { name = "pyjwt", extras = ["crypto"], marker = "extra == 'backend'", specifier = ">=2.14.0" }, - { name = "pyjwt", extras = ["crypto"], marker = "extra == 'dev'", specifier = ">=2.14.0" }, + { name = "pyjwt", extras = ["crypto"], marker = "extra == 'backend'", specifier = ">=2.15.1" }, + { name = "pyjwt", extras = ["crypto"], marker = "extra == 'dev'", specifier = ">=2.15.1" }, { name = "pyshacl", marker = "extra == 'dev'", specifier = ">=0.26.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "rankweave", git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6" }, { name = "rdflib", specifier = ">=7.0.0" }, { name = "redis", marker = "extra == 'backend'", specifier = ">=5.0.1" }, { name = "threadweave", specifier = ">=0.1.0" }, + { name = "urllib3", specifier = ">=2.8.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'backend'", specifier = ">=0.30.0" }, ] provides-extras = ["dev", "backend"] @@ -1576,11 +1578,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] From abf66f896992e1199829aa30a3e16406e779d272 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 15:19:13 +0900 Subject: [PATCH 3/4] fix(voice): retain evidence history across additional assignments --- CHANGELOG.d/voice-cutoff-reassertion.md | 5 + backend/app/main.py | 1 + .../app/ontology_neighborhood_ingestion.py | 1 + backend/app/source_post_voice_ingestion.py | 56 +++- ...256-evidence-bearing-voice-combinations.md | 21 +- docs/product-requirements.md | 2 + tests/test_source_post_voice_history_live.py | 259 +++++++++++++++++- tests/test_source_post_voice_ingestion.py | 93 ++++++- 8 files changed, 408 insertions(+), 30 deletions(-) create mode 100644 CHANGELOG.d/voice-cutoff-reassertion.md diff --git a/CHANGELOG.d/voice-cutoff-reassertion.md b/CHANGELOG.d/voice-cutoff-reassertion.md new file mode 100644 index 000000000..63ab384c8 --- /dev/null +++ b/CHANGELOG.d/voice-cutoff-reassertion.md @@ -0,0 +1,5 @@ +# Additional perspective history + +Revising a connected perspective preserves the earlier evidence and evidence +status in historical views. Repeating an unchanged connection keeps its +original history. Previously overwritten evidence remains unavailable. diff --git a/backend/app/main.py b/backend/app/main.py index 122165990..09380ec4c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -744,6 +744,7 @@ async def _load_post_voice_types( or ($2::timestamptz is not null and voice.effective_from <= $2 and (voice.effective_to is null or $2 < voice.effective_to))) + and ($2::timestamptz is null or voice.is_primary or voice.recorded_at <= $2) order by voice.is_primary desc, lookup.display_order, voice.voice_type_code """, post_id, diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py index 51f56ff2c..5f981d248 100644 --- a/backend/app/ontology_neighborhood_ingestion.py +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -914,6 +914,7 @@ async def _load_voice_assignments( or coalesce($2::timestamptz, $3::timestamptz) < voice.effective_to ) and voice.recorded_at <= $3::timestamptz + and (voice.is_primary or voice.recorded_at <= coalesce($2::timestamptz, $3::timestamptz)) order by voice.post_id, voice.is_primary desc, lookup.display_order, voice.voice_type_code """, diff --git a/backend/app/source_post_voice_ingestion.py b/backend/app/source_post_voice_ingestion.py index 609a71f4f..d975fe7e0 100644 --- a/backend/app/source_post_voice_ingestion.py +++ b/backend/app/source_post_voice_ingestion.py @@ -88,9 +88,17 @@ async def persist_additional_voice_assignment( truth_status_code: str, evidence_post_id: str, ) -> None: - """Atomically bind one additional Voice to an authorized evidence post.""" + """Bind an additional Voice without rewriting earlier cutoff evidence.""" assignment_iri = str(LW[f"voice-assignment/{post_id}/{voice_type_code}"]) async with conn.transaction(): + primary_code = await conn.fetchval( + "select voc_type_code from source_post where post_id = $1::uuid for update", + post_id, + ) + if primary_code == voice_type_code: + raise PrimaryVoiceAssignmentError( + "the imported primary Voice cannot be changed through the additional-voice path" + ) evidence_resource_id = await _post_resource_id(conn, evidence_post_id) assignment_resource_id = await conn.fetchval( """ @@ -139,28 +147,50 @@ async def persist_additional_voice_assignment( ) if assertion_id is None: raise RuntimeError("Voice evidence derivation was not persisted") - stored = await conn.fetchrow( + current = await conn.fetchrow( + """ + select voice_assignment_id, is_primary, truth_status_code, + provenance_assertion_id + from source_post_voice + where post_id = $1::uuid and voice_type_code = $2 + and effective_to is null + """, + post_id, + voice_type_code, + ) + if current is not None: + if current["is_primary"]: + raise PrimaryVoiceAssignmentError( + "the imported primary Voice cannot be changed through the additional-voice path" + ) + if ( + current["truth_status_code"] == truth_status_code + and current["provenance_assertion_id"] == assertion_id + ): + return + change_at = await conn.fetchval("select clock_timestamp()") + if current is not None: + await conn.execute( + """ + update source_post_voice set effective_to = $2 + where voice_assignment_id = $1::uuid and effective_to is null + """, + current["voice_assignment_id"], + change_at, + ) + await conn.execute( """ insert into source_post_voice (post_id, voice_type_code, is_primary, truth_status_code, provenance_assertion_id, effective_from, recorded_at) - values ($1::uuid, $2, false, $3, $4::uuid, now(), now()) - on conflict (post_id, voice_type_code) where effective_to is null do update - set truth_status_code = excluded.truth_status_code, - provenance_assertion_id = excluded.provenance_assertion_id, - recorded_at = now() - where not source_post_voice.is_primary - returning voice_type_code + values ($1::uuid, $2, false, $3, $4::uuid, $5, $5) """, post_id, voice_type_code, truth_status_code, assertion_id, + change_at, ) - if stored is None: - raise PrimaryVoiceAssignmentError( - "the imported primary Voice cannot be changed through the additional-voice path" - ) __all__ = ["PrimaryVoiceAssignmentError", "persist_additional_voice_assignment"] diff --git a/docs/adr/0256-evidence-bearing-voice-combinations.md b/docs/adr/0256-evidence-bearing-voice-combinations.md index 79279130d..eb13abad1 100644 --- a/docs/adr/0256-evidence-bearing-voice-combinations.md +++ b/docs/adr/0256-evidence-bearing-voice-combinations.md @@ -25,6 +25,23 @@ attributes rather than from one exhaustive industry-role list. Represent composition as rows in normalized `source_post_voice`, not as compound lookup codes. +Additional-assignment reassertion (2026-10-01): changing an additional Voice's +truth state or derivation evidence closes its current half-open interval and +inserts a new assignment interval. The closed row retains its original truth +state, assertion, start, and recording time; historical reads must not acquire +later evidence or lose an earlier assertion. Repeating the same truth state +and derivation is idempotent and retains the existing interval. The write locks +the carrying Post before reading its current Voice, serializing with other +assignments and imported-primary changes. The replacement boundary comes from +the database clock after that lock, not the transaction's possibly earlier +start time. A failed replacement rolls back the interval close and provenance +writes together. Previously overwritten evidence cannot be reconstructed: +an additional row recorded after the requested cutoff is omitted, even if its +old start predates that cutoff. The imported-primary source-time contract +remains governed by ADR 0252. +In-place upsert is rejected because it destroys cutoff evidence; an inferred +repair of old intervals is rejected because the overwritten evidence is absent. + - The existing `source_post.voc_type_code` remains the authoritative imported primary voice. A trigger mirrors it into exactly one primary association so existing import, filtering, and lineage behavior remains stable. @@ -63,8 +80,8 @@ compound lookup codes. - A `post_admin` may add an additional assignment by naming an ABAC-visible evidence Post, an atomic Voice code, and a governed truth state. The API does not accept a caller-supplied assertion identifier: one transaction binds the - evidence Post as a PROV Entity, records `prov:wasDerivedFrom`, and upserts the - assignment. It cannot replace or demote the imported primary Voice. + evidence Post as a PROV Entity, records `prov:wasDerivedFrom`, and records the + effective assignment interval. It cannot replace or demote the imported primary Voice. - In the live Post popup, a `post_admin` may choose one unassigned atomic Voice and one explicit truth state. The open Post is submitted as its own evidence, which covers a single record that contains several perspectives without diff --git a/docs/product-requirements.md b/docs/product-requirements.md index a8b741521..b3e370904 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -58,6 +58,8 @@ edge exposes the same authorized endpoints and evidence through API and UI. authorized Post as evidence and hide the write action on cutoff views. - Validate DB-to-RDF projections with SHACL, including complete reified ProjectMention subject/predicate/object chains. +- Preserve an additional perspective's earlier truth state and evidence when + it is revised; an unchanged retry retains its original availability time. - Keep SKOS broader/narrower distinct from OWL subclass semantics. Acceptance: Turtle, JSON-LD, N-Triples, SHACL, API payloads, persisted IRIs, diff --git a/tests/test_source_post_voice_history_live.py b/tests/test_source_post_voice_history_live.py index 4ae774d2e..d640e28ad 100644 --- a/tests/test_source_post_voice_history_live.py +++ b/tests/test_source_post_voice_history_live.py @@ -7,11 +7,13 @@ from __future__ import annotations +import asyncio import os import subprocess import threading import uuid from datetime import datetime, timedelta +from itertools import pairwise from pathlib import Path from urllib.parse import urlsplit, urlunsplit @@ -19,6 +21,8 @@ import psycopg2.errors import pytest +from backend.app.source_post_voice_ingestion import persist_additional_voice_assignment + _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" ) @@ -100,6 +104,14 @@ def voice_history_dsn(): database_dsn = _database_dsn(database_name) try: _apply_migrations(database_dsn) + with _connect(database_dsn) as connection, connection.cursor() as cursor: + cursor.execute( + """ + insert into common_lookup_value (lookup_category, lookup_code, lookup_label) + values ('knowledge_graph_node_type', 'node_post', 'Post') + on conflict (lookup_code) do nothing + """ + ) yield database_dsn finally: admin_conn = psycopg2.connect(_ADMIN_DSN) @@ -180,6 +192,235 @@ def _primary_rows(cursor, post_id: str) -> list[tuple]: return cursor.fetchall() +def test_additional_voice_reassertion_preserves_cutoff_evidence( + voice_history_dsn, +) -> None: + """Later truth/evidence writes cannot rewrite an earlier additional Voice.""" + import asyncpg + + from backend.app.main import _load_post_voice_types + from backend.app.ontology_neighborhood_ingestion import _load_voice_assignments + + with _connect(voice_history_dsn) as connection, connection.cursor() as cursor: + post_id = _insert_synthetic_post(cursor) + first_evidence = _insert_synthetic_post(cursor) + later_evidence = _insert_synthetic_post(cursor) + + async def exercise(): + conn = await asyncpg.connect(voice_history_dsn) + try: + + async def assign(truth, evidence): + await persist_additional_voice_assignment( + conn, + post_id=post_id, + voice_type_code="vops", + truth_status_code=truth, + evidence_post_id=evidence, + ) + + await assign("truth_proposed", first_evidence) + first = await conn.fetchrow( + "select * from source_post_voice where post_id = $1::uuid and not is_primary", + post_id, + ) + cutoff = await conn.fetchval("select clock_timestamp()") + await assign("truth_proposed", first_evidence) + assert ( + await conn.fetchrow( + "select * from source_post_voice where voice_assignment_id = $1", + first["voice_assignment_id"], + ) + == first + ) + await assign("truth_observed", first_evidence) + await assign("truth_observed", later_evidence) + rows = await conn.fetch( + """ + select voice.*, binding.node_id as evidence_post_id + from source_post_voice voice + join provenance_assertion assertion + on assertion.assertion_id = voice.provenance_assertion_id + join provenance_resource_binding binding + on binding.resource_id = assertion.object_resource_id + where voice.post_id = $1::uuid and not voice.is_primary + order by voice.effective_from + """, + post_id, + ) + assert len(rows) == 3 + old, intermediate, new = rows + assert old["voice_assignment_id"] != new["voice_assignment_id"] + assert old["truth_status_code"] == "truth_proposed" + assert str(old["evidence_post_id"]) == first_evidence + assert old["recorded_at"] == first["recorded_at"] + assert old["effective_from"] == first["effective_from"] + assert old["effective_to"] == intermediate["effective_from"] + assert intermediate["truth_status_code"] == "truth_observed" + assert str(intermediate["evidence_post_id"]) == first_evidence + assert ( + intermediate["effective_to"] + == new["effective_from"] + == new["recorded_at"] + ) + assert new["truth_status_code"] == "truth_observed" + assert str(new["evidence_post_id"]) == later_evidence + assert new["effective_to"] is None + before_failure = await conn.fetchrow( + "select * from source_post_voice where voice_assignment_id = $1", + new["voice_assignment_id"], + ) + with pytest.raises(asyncpg.CheckViolationError): + await assign("truth_unsupported", first_evidence) + assert ( + await conn.fetchrow( + "select * from source_post_voice where voice_assignment_id = $1", + new["voice_assignment_id"], + ) + == before_failure + ) + assert ( + await conn.fetchval( + "select count(*) from source_post_voice where post_id=$1::uuid and not is_primary", + post_id, + ) + == 3 + ) + historical = await conn.fetchrow( + """ + select voice_assignment_id, truth_status_code, provenance_assertion_id + from source_post_voice + where post_id = $1::uuid and not is_primary + and effective_from <= $2 and recorded_at <= $2 + and (effective_to is null or $2 < effective_to) + """, + post_id, + cutoff, + ) + assert historical["voice_assignment_id"] == first["voice_assignment_id"] + assert historical["truth_status_code"] == first["truth_status_code"] + assert ( + historical["provenance_assertion_id"] + == first["provenance_assertion_id"] + ) + assert ( + await conn.fetchval( + "select voice_type_code from source_post_voice where post_id=$1::uuid and is_primary and effective_to is null", + post_id, + ) + == "voc" + ) + detail = await _load_post_voice_types(conn, post_id, cutoff) + assert [ + (voice["code"], voice["truth_status_code"]) for voice in detail + ] == [ + ("voc", "truth_observed"), + ("vops", "truth_proposed"), + ] + snapshot_at = await conn.fetchval("select clock_timestamp()") + assignments = await _load_voice_assignments( + conn, + [post_id, first_evidence, later_evidence], + knowledge_cutoff=cutoff, + snapshot_at=snapshot_at, + ) + earlier = next(voice for voice in assignments if not voice.is_primary) + assert earlier.evidence_post_id == first_evidence + assert earlier.truth_status_code == "truth_proposed" + # Emulate a legacy overwritten row whose original truth is lost. + await conn.execute( + "update source_post_voice set recorded_at=clock_timestamp() where voice_assignment_id=$1", + first["voice_assignment_id"], + ) + assert [ + voice["code"] + for voice in await _load_post_voice_types(conn, post_id, cutoff) + ] == ["voc"] + assignments = await _load_voice_assignments( + conn, + [post_id, first_evidence, later_evidence], + knowledge_cutoff=cutoff, + snapshot_at=await conn.fetchval("select clock_timestamp()"), + ) + assert all(voice.is_primary for voice in assignments) + finally: + await conn.close() + + asyncio.run(exercise()) + + +def test_waiting_additional_voice_writer_uses_post_lock_clock( + voice_history_dsn, +) -> None: + """An earlier-started transaction cannot backdate a replacement after waiting.""" + import asyncpg + + with _connect(voice_history_dsn) as connection, connection.cursor() as cursor: + post_id = _insert_synthetic_post(cursor) + evidence = _insert_synthetic_post(cursor) + + async def exercise(): + first = await asyncpg.connect(voice_history_dsn) + second = await asyncpg.connect(voice_history_dsn) + observer = await asyncpg.connect(voice_history_dsn) + pending = None + try: + + async def assign(conn, truth): + await persist_additional_voice_assignment( + conn, + post_id=post_id, + voice_type_code="vops", + truth_status_code=truth, + evidence_post_id=evidence, + ) + + await assign(first, "truth_proposed") + second_pid = await second.fetchval("select pg_backend_pid()") + async with first.transaction(): + await first.fetchval( + "select post_id from source_post where post_id=$1::uuid for update", + post_id, + ) + pending = asyncio.create_task(assign(second, "truth_authoritative")) + async with asyncio.timeout(5): + while not await observer.fetchval( + "select wait_event_type = 'Lock' from pg_stat_activity where pid=$1", + second_pid, + ): + await asyncio.sleep(0.001) + await assign(first, "truth_observed") + await asyncio.wait_for(pending, 5) + rows = await first.fetch( + """ + select truth_status_code, effective_from, effective_to, recorded_at + from source_post_voice where post_id=$1::uuid and not is_primary + order by effective_from + """, + post_id, + ) + assert [row["truth_status_code"] for row in rows] == [ + "truth_proposed", + "truth_observed", + "truth_authoritative", + ] + for old, new in pairwise(rows): + assert ( + old["effective_from"] < old["effective_to"] == new["effective_from"] + ) + assert new["recorded_at"] == new["effective_from"] + assert rows[-1]["effective_to"] is None + finally: + if pending is not None and not pending.done(): + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) + await first.close() + await second.close() + await observer.close() + + asyncio.run(exercise()) + + def _api_primary(cursor, post_id: str, cutoff: datetime | None) -> list[str]: cursor.execute( _API_CUTOFF_SQL, @@ -251,7 +492,9 @@ def _insert_additional_voice(cursor, post_id: str, voice_type_code: str) -> None ) -def test_aba_primary_history_matches_api_and_ontology_cutoffs(voice_history_dsn: str) -> None: +def test_aba_primary_history_matches_api_and_ontology_cutoffs( + voice_history_dsn: str, +) -> None: """A → B → A is recoverable at before / between / after cutoffs.""" connection = _connect(voice_history_dsn) try: @@ -294,10 +537,16 @@ def test_aba_primary_history_matches_api_and_ontology_cutoffs(voice_history_dsn: snapshot_during_b = between snapshot_after = after_last - assert _ontology_primary(cursor, post_id, None, snapshot_during_b) == ["vops"] + assert _ontology_primary(cursor, post_id, None, snapshot_during_b) == [ + "vops" + ] assert _ontology_primary(cursor, post_id, None, snapshot_after) == ["voc"] - assert _ontology_primary(cursor, post_id, first_from, snapshot_after) == ["voc"] - assert _ontology_primary(cursor, post_id, between, snapshot_after) == ["vops"] + assert _ontology_primary(cursor, post_id, first_from, snapshot_after) == [ + "voc" + ] + assert _ontology_primary(cursor, post_id, between, snapshot_after) == [ + "vops" + ] cursor.execute( """ @@ -442,7 +691,7 @@ def _update(next_code: str) -> None: (next_code, post_id), ) connection.commit() - except Exception as exc: + except (psycopg2.Error, threading.BrokenBarrierError) as exc: errors.append(exc) connection.rollback() finally: diff --git a/tests/test_source_post_voice_ingestion.py b/tests/test_source_post_voice_ingestion.py index 52fdf0b1f..cdeb7d38b 100644 --- a/tests/test_source_post_voice_ingestion.py +++ b/tests/test_source_post_voice_ingestion.py @@ -4,6 +4,7 @@ import asyncio from contextlib import asynccontextmanager +from datetime import UTC, datetime from typing import Any import pytest @@ -18,19 +19,30 @@ class _Connection: """Record the ordered SQL contract without requiring a live database.""" def __init__( - self, *, primary_conflict: bool = False, existing_evidence: bool = False + self, + *, + existing_evidence: bool = False, + current: dict[str, object] | None = None, ) -> None: - self.primary_conflict = primary_conflict + self.current = current self.calls: list[tuple[str, tuple[object, ...]]] = [] self.fetchvals = iter( - ["evidence-resource", "assignment-resource", "assertion"] + [ + "voc", + "evidence-resource", + "assignment-resource", + "assertion", + datetime(2026, 10, 1, tzinfo=UTC), + ] if existing_evidence else [ + "voc", None, "evidence-resource", "evidence-resource", "assignment-resource", "assertion", + datetime(2026, 10, 1, tzinfo=UTC), ] ) @@ -48,10 +60,10 @@ async def fetchval(self, query: str, *args: object) -> Any: self.calls.append((query, args)) return next(self.fetchvals) - async def fetchrow(self, query: str, *args: object) -> dict[str, str] | None: - """Return no row only when the imported primary blocks the write.""" + async def fetchrow(self, query: str, *args: object) -> dict[str, object] | None: + """Return the existing additional interval, if supplied.""" self.calls.append((query, args)) - return None if self.primary_conflict else {"voice_type_code": str(args[1])} + return self.current def test_additional_voice_creates_prov_derivation_and_assignment_atomically() -> None: @@ -70,9 +82,9 @@ def test_additional_voice_creates_prov_derivation_and_assignment_atomically() -> sql = "\n".join(query for query, _args in conn.calls) assert "prov_was_derived_from" in sql - assert "where effective_to is null" in sql - assert "where not source_post_voice.is_primary" in sql - assert "where effective_to is null" in sql + assert "and effective_to is null" in sql + assert "for update" in sql + assert "select clock_timestamp()" in sql assert "voice-assignment/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1/vops" in str( conn.calls ) @@ -80,7 +92,7 @@ def test_additional_voice_creates_prov_derivation_and_assignment_atomically() -> def test_additional_voice_cannot_demote_imported_primary() -> None: """The current primary remains owned by source_post.voc_type_code.""" - conn = _Connection(primary_conflict=True) + conn = _Connection() with pytest.raises(PrimaryVoiceAssignmentError): asyncio.run( @@ -112,3 +124,64 @@ def test_existing_evidence_binding_is_typed_as_a_prov_entity() -> None: "provenance_resource_type" in query and args == ("evidence-resource",) for query, args in conn.calls ) + + +def test_repeating_the_same_truth_and_evidence_retains_the_interval() -> None: + """A retry cannot move the original availability clock or create history.""" + conn = _Connection( + current={ + "voice_assignment_id": "current-assignment", + "is_primary": False, + "truth_status_code": "truth_observed", + "provenance_assertion_id": "assertion", + } + ) + asyncio.run( + persist_additional_voice_assignment( + conn, + post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + voice_type_code="vops", + truth_status_code="truth_observed", + evidence_post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2", + ) + ) + assert not any( + "update source_post_voice" in query or "insert into source_post_voice" in query + for query, _args in conn.calls + ) + + +def test_replacement_closes_only_the_previous_interval() -> None: + """A new truth state keeps the old assertion and its recording time intact.""" + conn = _Connection( + current={ + "voice_assignment_id": "old-assignment", + "is_primary": False, + "truth_status_code": "truth_proposed", + "provenance_assertion_id": "old-assertion", + } + ) + asyncio.run( + persist_additional_voice_assignment( + conn, + post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + voice_type_code="vops", + truth_status_code="truth_observed", + evidence_post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2", + ) + ) + close = next( + (query, args) + for query, args in conn.calls + if "update source_post_voice" in query + ) + insert = next( + (query, args) + for query, args in conn.calls + if "insert into source_post_voice" in query + ) + assert close[1][0] == "old-assignment" + assert close[1][1] == insert[1][4] + assert "recorded_at =" not in close[0] + assert "truth_status_code =" not in close[0] + assert "provenance_assertion_id =" not in close[0] From e627d90e6daed0836a6602573d9d50a57d957601 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 15:45:26 +0900 Subject: [PATCH 4/4] docs(gaps): record exact-head queue and cutoff repair evidence --- docs/product-technical-gap-baseline.md | 196 +++++++++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b5d31877b..9e5a99086 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,201 @@ # Product & Technical Gap Baseline +## Current exact-head audit — 2026-10-01 + +This audit supersedes older present-tense queue and delivery statements below. +It separates normative decisions, research/design authority, code candidates, +hosted protection, and observed runtime state. It is not a release claim. + +### Authority and canonical repository identity + +Before changing code, this loop read LineageWeave's current +`docs/product-requirements.md`, ADR 0246, ADR 0251, ADR 0256, and the existing +primary-history contract. Twelve atomic Voices remain an open composition +vocabulary; no compound-code enumeration or B2B2C restriction is introduced. +ADR 0251 governs the I/O-psychology semantic layer, while ADR 0256 governs +evidence-bearing Voice combinations. Their meanings are not interchanged. + +GraphQL repository identities and Git default heads agreed on these canonical +names. The ecosystem sources were read before implementation; an approved +design or local document is not promoted to deployed behavior. + +| Canonical repository | Default head observed | Product/architecture authority read | +|---|---|---| +| `ContextualWisdomLab/LineageWeave` | `83eba56149eb802cd63642c507c324c9976ec78e` | Current PRD and governing ADRs | +| `ContextualWisdomLab/RankWeave` | `92323cb8b55baf5d840cb97fa8534a0e75ef234c` | `ARCHITECTURE.md` | +| `ContextualWisdomLab/ThreadWeave` | `0fda6e60c2c80ec7b2aa2d58dac6b944dec6a6d0` | `docs/PRD.md` | +| `ContextualWisdomLab/disksage` | `05899ffb01ce91a9ea3d782630b28a398de59ddc` | Local `docs/PRD.md`; protected-head `README.md` and `docs/architecture/adr/README.md` | +| `ContextualWisdomLab/TEPP` | `a243f18da4a4ca8a8d068c39922537f1f8ed6ad0` | `docs/product/prd-v0.4-approved.md` | +| `ContextualWisdomLab/contextual-orchestrator` | Not refreshed in this snapshot | `docs/product_planning.md`, `docs/architecture.md` | + +DiskSage's remote repository spelling is lowercase `disksage`. PR #1131 +owns the protected-main authority-register correction; its unmerged document +change is not silently treated as integrated here. No new ecosystem API, +provider policy, estimation, or mathematical implementation is introduced. + +### Queue and protection observed + +The paginated 06:17 UTC REST observation covered **177 open PRs**, **169 +drafts**, and **116 non-main bases**, with **43 open issues** in the preceding +GraphQL observation. PR #1139 was created afterward for the cutoff repair +below; these counts are explicitly the pre-creation inventory, not a live +counter. Every head/base object in that 177-PR inventory was available for a +read-only changed-file and contract-identity audit. +The later GraphQL refresh counted **178 open PRs** and **43 open issues**, +confirmed unchanged protected main, and confirmed #1137/#1139 were still open +with normal auto-merge and no merge commit. + +The active central ruleset `18156473` requires one approval, stale-review +dismissal, resolved review threads, and seven central workflows: OpenCode, +merge scheduler, Security Scan, Strix, Semgrep, Noema, and CodeQL. +`require_last_push_approval` was false on this read; the separate no-force-push +ruleset `21065108` remains active. No policy was changed. Exact-head approvals +and terminal required checks remain separate from local tests and dispatches. + +| PR | Exact head observed | Review | Normal squash auto-merge | +|---:|---|---|---| +| #1040 | `4d74c32a23cdc254cf5f4d4e72804fe54aa0f1af` | Approved | Enabled | +| #1129 | `afff1ef480a4d4eee5ae55c466ff6364df6e804e` | Required | Enabled | +| #1130 | `383c392bc6713e55bed31b4d4053d93cfd1885d0` | Approved | Enabled | +| #1131 | `ee3d8890ce3b7829f668e05732ef55d24e2e688e` | Required | Enabled | +| #1133 | `1420a733eb30cea5198dffc2ae08734c9cfe521e` | Required | Enabled | +| #1135 | `30392ee9ef7f5ff3a234e30711bef58ccb9e7b11` | Required | Enabled | +| #1136 | `55f6992637c53cfb51a74f55987a40b359152bd5` | Required | Enabled | +| #1137 | `db96ff11c977a92180b5480884bc361a4be5cf75` | Required | Enabled | + +The exact-head protection refresh found zero unresolved threads on these +eight ready PRs, with no truncated thread page; all remained BLOCKED. +Later REST check-run refreshes returned HTTP 403 rate-limit responses despite +the quota endpoint reporting remaining capacity. Earlier check results below +remain dated observations; that API inconsistency is not passing evidence. +#1040 and #1130 retain exact-head independent Noema approvals, but their +OpenCode/CodeQL failures still prevent protected delivery. #1137's patched +dependency/JWKS tests passed 20 local tests and `uv lock --check`; its Trivy +and OSV findings are cleared, while exact-head `dependency-review` still +fails its support probe with HTTP 403. Central owner `.github` #1725 remains +open/Draft at `f27c5cfa4a61679e6ebb109d9e5972bd8a4f650d`, not a delivered fix. +#1137 was made ready and normal auto-merge enabled after current-head local +verification; the failing permission/configuration gate remains intact. + +No protected merge SHA was produced by this loop. Protected `main` stayed +`83eba56149eb802cd63642c507c324c9976ec78e`. The in-progress/queued run inventory +showed only an open #1129 exact-head Noema run and no queued runs on that +bounded read, so no stale run was cancelled and no workflow gate was weakened. + +### Selected user gap and candidate proof + +The selected gap is loss of historical evidence when an additional perspective +is revised. Protected-main persistence overwrote the current truth state, +derivation assertion, and recording time, allowing a cutoff view to lose an +earlier claim or acquire later evidence. + +PR #1139 code head `abf66f896992e1199829aa30a3e16406e779d272` closes the prior +interval and inserts a replacement after locking the carrying Post. The old +truth, derivation, and clocks stay intact; an identical retry is idempotent. +The database clock is sampled after the lock, so an earlier-started waiting +writer cannot backdate its replacement. Post-detail and ontology projections +omit additional rows recorded after the cutoff, including legacy overwrites +whose earlier evidence cannot be recovered. ADR 0256 was amended before code; +the imported primary and atomic vocabulary remain unchanged. + +At that code head, **89 related tests passed with DeprecationWarning treated +as an error**, including synthetic PostgreSQL migration replay, truth-only +and evidence-only changes, unchanged retry, failed-replacement rollback, +waiting writers, historical production read projections, and primary-Voice +preservation. Ruff on persistence/regression files and `git diff --check` +passed. Five documentation-hygiene +tests also passed before this baseline refresh. Temporary test databases are +dropped by fixture teardown; no formal data volume was removed. + +This baseline-only follow-up advances the PR head without changing the tested +code. Its hosted checks and independent approval must be collected again; +the code-head tests above are not transferred approval or protected delivery. +On the pre-baseline code head, GitHub's frontend job and Semgrep passed; +Full suite, Noema, and Strix remained in progress. CodeQL compatibility, +OpenCode, and Trivy remained failed. The inherited vulnerable dependency lock +is owned by #1137, not repaired through a duplicate consumer dependency change. +Normal squash auto-merge was enabled for #1139; required review remained +unmet, with no unresolved threads and no merge SHA. + +### Cross-PR contract and integration audit + +The 177-PR audit used each observed base/head pair, excluded deleted files and +rollback companions, and compared changed contract identities. It found these +unresolved integration risks rather than silently renumbering another owner's +work: + +- Migration ordinal 0248: #1049's source-conversation evidence and #929's + Customer Master translation draft. Ordinal 0249: #1047's chat authorization + and #1127's translation ownership/draft files. #929 additionally retains + multiple distinct 0247 files. Issue #1048/#1049 remain the ordinal-owner lane. +- ADR identity reuse includes 0245 (#702 versus #997/#1123), 0272 + (#1009 versus #888 versus #802/#850), 0300 (#899 versus #837/#857), + 0301 (#902 versus #838), and 0355 (#915 versus #920), plus the overlapping + leftover-map 0289-0305 family. Matching ordinals do not establish equivalent + decisions; accepted authority and parent-first delivery must reconcile them. +- Repeated release changes occur at 2.54.0 (#828/#832/#833), 2.55.0 + (#829/#835), 2.56.0 (#830/#836/#980), 2.61.0 (#841/#842), and 2.62.0 + (#843/#844). These are candidate identities, not simultaneous releases. +- No duplicate newly added method/path identity was found among the changed + `backend/app/main.py` handlers. That bounded check does not prove request, + response, authorization, schema, or migration compatibility. + +The #1139 code tree merged cleanly in read-only merge-tree checks with exact +#1129, #1138, #1131, and #1135 heads above. #1129 owns searched Voice exports +and paged JSON-LD union; #1138 owns derivation admission and is stacked on +#1137. Preserve both derivation admission and cutoff predicates when composing +their shared ingestion file. Baseline overlays remain a shared-file conflict +risk. #1132 still waits on #1131; #1138 still waits on #1137. Merge parents +through protection first, then retarget children to main and recollect evidence. +This PR introduces no new ADR ordinal, migration, API shape, or release number. + +### Runtime and acceptance remain distinct + +The formal Compose project is `lineageweave`. A fresh bounded inspection +observed PostgreSQL running/unhealthy, the backend running, the Ask worker +exited/unhealthy, and Valkey running/healthy. Redacted worker diagnostics +contained OSError, asyncpg InterfaceError, and Valkey timeout classes; they +do not establish a saturation cause or justify an unmeasured tuning policy. +No raw logs, provider responses, source records, credentials, or rendered +Compose configuration were printed or committed. + +Authenticated synthetic k6 concurrency, latency, error-rate, throughput, and +PostgreSQL/worker/Valkey/gateway saturation evidence are **unavailable** in this +run. An authenticated synthetic workload and healthy formal services must be +established before measuring or claiming capacity; only an observed bottleneck +may authorize a performance fix. No population inference is drawn from a +runtime convenience sample or service-health observation. + +The PostgreSQL tests above exercise production projections but do not prove +authenticated HTTP admission or rendered runtime UI. Voice acceptance therefore +remains **incomplete**. Carrying Post versus derivation evidence, hidden-evidence +omission, paged JSON-LD property/multi-Voice preservation, truth/cutoff behavior, +and desktop/mobile rendering must converge on one protected head before a +release claim. No estimation, model selection, provider passthrough, fabricated +weight, fallback evidence, or unsupported completion is added. + +The separate #1129 UI candidate was rebuilt from exact head +`afff1ef480a4d4eee5ae55c466ff6364df6e804e` with an isolated Corepack/pnpm install. +All **537 frontend tests** passed in a single-worker run, including the 19 +focused Voice/export tests; Storybook built. An initial unrestricted local +invocation failed 21 tests, which did not reproduce on the identical head with +one worker. No timeout, assertion, or source code was changed to obtain that +result, and the failed run is not counted as passing. + +Fresh `SeparateVoiceEvidence` screenshots were inspected at 1440×900 and +390×844, including the mobile evidence-action scroll position. Page width +stayed within each viewport; the mobile exact-values region retained its +317-pixel viewport and 638-pixel scrollable table. The carrying Post and +different derivation Post have separate actions. Browser checks confirmed that +excluding the evidence Post removes the additional Voice from the view, CSV, +and JSON-LD instead of substituting the carrying Post. Paged subject/property +and multi-Voice union remains covered by the focused tests. These synthetic +render/export results belong to #1129, not authenticated runtime or #1139's +protected delivery; the screenshots/exports are local review artifacts under +`/tmp/lw-root-visual-audit/` and contain synthetic fixture records only. + +## Historical supporting snapshots + > Exact-head loop overlay: 2026-08-29 13:20 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent