Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.d/voice-cutoff-reassertion.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions backend/app/ontology_neighborhood_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
""",
Expand Down
56 changes: 43 additions & 13 deletions backend/app/source_post_voice_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"""
Expand Down Expand Up @@ -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"]
21 changes: 19 additions & 2 deletions docs/adr/0256-evidence-bearing-voice-combinations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions docs/doctoring/lineageweave-dependency-security-20261001.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/product-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ dependencies = [
# Explicit CA bundle for http_client HTTPS posts -- some interpreter
# distributions don't reliably inherit the OS trust store.
"certifi>=2024.0.0",
"cryptography>=42.0",
# Explicit floor for the transport dependency after CVE-2026-97687/97689.
# 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
# support profile (ADR 0011). Pure Python, no Rust/C toolchain.
Expand Down
73 changes: 73 additions & 0 deletions tests/test_pyjwt_advisory_floor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Keep dependency locks outside the PyJWT and urllib3 advisory ranges."""

from __future__ import annotations

import re
import tomllib
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
PYJWT_PATCHED_VERSION = (2, 15, 1)
URLLIB3_PATCHED_VERSION = (2, 8, 0)


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 dependency 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.15.1"]


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(
_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
)
Loading
Loading