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
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,18 @@ contextual-orchestrator owns model discovery and selection.
region-level evidence; never show an internal LLM instruction such as
`This post is an image` to a buyer.

## Observability boundary

- Follow governance-risk-compliance ADR 0009 and LineageWeave ADR 0122 for
OpenTelemetry. Use `OTEL_SERVICE_NAME` and
`OTEL_EXPORTER_OTLP_ENDPOINT`; exporting is opt-in and provider-neutral.
- Correlate one post's HTTP, contextual-orchestrator, and Valkey work with the
existing post-scoped session metadata. Do not create an ad hoc session table.
- Telemetry may contain bounded operation, route-template, service-peer, and
correlation attributes, but never post body, prompt, answer, source content,
actor or tenant identifiers, credentials, raw stream keys, or provider
responses. GRC remains the control/evidence owner.

## Source parsing and semantic units

- Preserve the source representation and provenance, then derive semantic
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.d/2.13.1-buyer-safe-image-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# 2.13.1 Buyer-safe image evidence

Buyer views and search keep legitimate captions while suppressing internal
VISION instructions. Region responses with invalid JSON value types now fail
closed with an explicit type error instead of being treated as valid evidence.
46 changes: 32 additions & 14 deletions backend/app/activity_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import redis.asyncio as redis
from fastapi import Request

from lineageweave.observability import traced


def create_valkey_client(url: str) -> redis.Redis:
"""One shared async client for the process, mirroring db.create_pool."""
Expand Down Expand Up @@ -66,12 +68,16 @@ async def publish_activity_event(
``approximate=True``) so one very active post's stream can't grow
without bound -- the panel only ever shows the most recent 50 anyway.
"""
return await client.xadd(
_stream_key(post_id),
_activity_fields(event_type, actor_account_id, summary),
maxlen=1000,
approximate=True,
)
with traced(
"lineageweave.valkey.activity_xadd",
{"db.system": "redis", "db.operation.name": "xadd", "lineageweave.stream.kind": "activity"},
):
return await client.xadd(
_stream_key(post_id),
_activity_fields(event_type, actor_account_id, summary),
maxlen=1000,
approximate=True,
)


def publish_activity_event_sync(
Expand All @@ -83,20 +89,32 @@ def publish_activity_event_sync(
) -> str | None:
"""Sync ``XADD`` for ``make seed``. Returns None if ``summary`` is already on the stream."""
key = _stream_key(post_id)
existing = client.xrevrange(key, count=50)
with traced(
"lineageweave.valkey.activity_xrevrange",
{"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"},
):
existing = client.xrevrange(key, count=50)
if any(fields.get("summary") == summary for _entry_id, fields in existing):
return None
return client.xadd(
key,
_activity_fields(event_type, str(actor_account_id), summary),
maxlen=1000,
approximate=True,
)
with traced(
"lineageweave.valkey.activity_xadd",
{"db.system": "redis", "db.operation.name": "xadd", "lineageweave.stream.kind": "activity"},
):
return client.xadd(
key,
_activity_fields(event_type, str(actor_account_id), summary),
maxlen=1000,
approximate=True,
)


async def read_activity_events(client: redis.Redis, post_id: str, count: int = 50) -> list[dict[str, Any]]:
"""The post's most recent events, newest first."""
entries = await client.xrevrange(_stream_key(post_id), count=count)
with traced(
"lineageweave.valkey.activity_xrevrange",
{"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"},
):
entries = await client.xrevrange(_stream_key(post_id), count=count)
return [
{
"event_id": entry_id,
Expand Down
26 changes: 16 additions & 10 deletions backend/app/analysis_run_outbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import redis.asyncio as redis

from lineageweave.observability import traced

OUTBOX_STREAM_KEY = "analysis-run-outbox"
_CLAIMED = "analysis_outbox_claimed"
_DELIVERED = "analysis_outbox_delivered"
Expand Down Expand Up @@ -69,16 +71,20 @@ async def publish_outbox_event(
if client is None:
return None
try:
entry_id = await client.xadd(
OUTBOX_STREAM_KEY,
outbox_stream_fields(
analysis_run_id=analysis_run_id,
work_kind_code=work_kind_code,
request_sha256=request_sha256,
),
maxlen=1000,
approximate=True,
)
with traced(
"lineageweave.valkey.analysis_outbox_xadd",
{"db.system": "redis", "db.operation.name": "xadd", "lineageweave.stream.kind": "analysis_outbox", "lineageweave.work_kind": work_kind_code},
):
entry_id = await client.xadd(
OUTBOX_STREAM_KEY,
outbox_stream_fields(
analysis_run_id=analysis_run_id,
work_kind_code=work_kind_code,
request_sha256=request_sha256,
),
maxlen=1000,
approximate=True,
)
except redis.RedisError:
return None
return str(entry_id)
Expand Down
11 changes: 10 additions & 1 deletion backend/app/analysis_run_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from uuid import UUID

from lineageweave.adjudication_client import AdjudicationClient
from lineageweave.observability import traced
from lineageweave.tepp_client import TeppClient

from backend.app.analysis_run_outbox import OUTBOX_STREAM_KEY
Expand All @@ -31,7 +32,15 @@ async def consume_analysis_run_stream_once(
Invalid or stale entries are acknowledged by advancing the cursor; the
durable PostgreSQL outbox remains available for a later explicit retry.
"""
batches = await client.xread({OUTBOX_STREAM_KEY: last_id}, count=10, block=1000)
with traced(
"lineageweave.valkey.analysis_outbox_xread",
{
"db.system": "redis",
"db.operation.name": "xread",
"lineageweave.stream.kind": "analysis_outbox",
},
):
batches = await client.xread({OUTBOX_STREAM_KEY: last_id}, count=10, block=1000)
Comment on lines +35 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Idle blocking reads emit a span every cycle

The worker loops forever calling client.xread(..., block=1000), now wrapped in traced. With an OTLP endpoint configured this produces a new ~1s span roughly once per second per worker even while the stream is idle. No-op when telemetry is off; verify the emitted volume is acceptable when it is on.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

for _stream_name, entries in batches:
for entry_id, fields in entries:
analysis_run_id = str(fields.get("analysis_run_id", "")).strip()
Expand Down
7 changes: 7 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@
has_real_source_context,
)
from lineageweave.http_client import HttpClientError
from lineageweave.observability import configure_telemetry

_POST_READ = "post_read"
_POST_ADMIN = "post_admin"
Expand All @@ -194,6 +195,7 @@
async def lifespan(app: FastAPI):
"""Open one asyncpg pool and one Valkey client for the process, and
close both on shutdown."""
configure_telemetry("lineageweave")
settings = load_settings()
app.state.pool = await create_pool(settings.database_url)
app.state.valkey = create_valkey_client(settings.valkey_url)
Expand Down Expand Up @@ -2655,6 +2657,11 @@ async def chat_about_post(
status.HTTP_503_SERVICE_UNAVAILABLE,
"Post chat is unavailable: contextual-orchestrator returned no complete evidence object",
) from exc
except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed.
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
"Post chat is unavailable: contextual-orchestrator returned no complete evidence object",
) from exc
Comment on lines +2660 to +2664

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Duplicate unreachable exception handler

Two identical except Exception as exc: blocks now follow the same try (backend/app/main.py:2655-2664). The second can never execute. It is dead copy-paste code, and flake8-bugbear's duplicate-try-block rule can fail on it since only BLE001 is suppressed.

Suggested change
except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed.
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
"Post chat is unavailable: contextual-orchestrator returned no complete evidence object",
) from exc
cited_ids = list(answer.cited_post_ids)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

cited_ids = list(answer.cited_post_ids)
async with pool.acquire() as conn:
await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids)
Expand Down
24 changes: 15 additions & 9 deletions backend/app/post_content_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import asyncpg
import redis.asyncio as redis

from lineageweave.observability import traced

POST_CONTENT_STREAM_KEY = "post-content-ingestion"
QUEUED = "post_content_ingestion_queued"
RUNNING = "post_content_ingestion_running"
Expand Down Expand Up @@ -126,15 +128,19 @@ async def publish_post_content_event(
if client is None:
return None
try:
entry_id = await client.xadd(
POST_CONTENT_STREAM_KEY,
post_content_stream_fields(
post_id=post_id,
source_body_digest=source_body_digest,
),
maxlen=1000,
approximate=True,
)
with traced(
"lineageweave.valkey.post_content_xadd",
{"db.system": "redis", "db.operation.name": "xadd", "lineageweave.stream.kind": "post_content"},
):
entry_id = await client.xadd(
POST_CONTENT_STREAM_KEY,
post_content_stream_fields(
post_id=post_id,
source_body_digest=source_body_digest,
),
maxlen=1000,
approximate=True,
)
except redis.RedisError:
return None
return str(entry_id)
Expand Down
23 changes: 20 additions & 3 deletions backend/app/post_content_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata
from lineageweave.post_content_normalization import normalize_post_body
from lineageweave.post_content_persistence import persist_post_content
from lineageweave.observability import traced
from lineageweave.post_structure import PostStructureClient

from backend.app.config import load_settings
Expand All @@ -42,7 +43,15 @@

async def _stream_tail(client: redis.Redis) -> str:
"""Start after historical wake-ups; the normalized ledger drives recovery."""
rows = await client.xrevrange(POST_CONTENT_STREAM_KEY, count=1)
with traced(
"lineageweave.valkey.post_content_xrevrange",
{
"db.system": "redis",
"db.operation.name": "xrevrange",
"lineageweave.stream.kind": "post_content",
},
):
rows = await client.xrevrange(POST_CONTENT_STREAM_KEY, count=1)
return str(rows[0][0]) if rows else "0-0"


Expand All @@ -57,7 +66,7 @@ async def _claim_job(
async with pool.acquire() as conn:
async with conn.transaction():
row = await conn.fetchrow(
f"""
"""
select p.*, j.source_body_sha256 as job_source_body_sha256,
j.status_code as job_status_code,
j.attempt_count as job_attempt_count,
Expand Down Expand Up @@ -284,7 +293,15 @@ async def consume_post_content_stream_once(
embedding_factory: Callable[[], EmbeddingClient],
structure_factory: Callable[[], PostStructureClient],
) -> str:
batches = await client.xread({POST_CONTENT_STREAM_KEY: last_id}, count=10, block=1000)
with traced(
"lineageweave.valkey.post_content_xread",
{
"db.system": "redis",
"db.operation.name": "xread",
"lineageweave.stream.kind": "post_content",
},
):
batches = await client.xread({POST_CONTENT_STREAM_KEY: last_id}, count=10, block=1000)
for _stream_name, entries in batches:
for entry_id, fields in entries:
post_id = str(fields.get("post_id", "")).strip()
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ services:
# explicit bounded 8 MiB limit rather than an unbounded request size.
CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES: ${CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES:-8388608}
CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS: ${CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS:-host.docker.internal}
OTEL_SERVICE_NAME: ${OTEL_ORCHESTRATOR_SERVICE_NAME:-contextual-orchestrator}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
command: ["python", "/app/start.py"]
ports:
- "${ORCHESTRATOR_PORT:-18000}:8000"
Expand Down Expand Up @@ -155,6 +157,8 @@ services:
OIDC_CLOCK_SKEW_SECONDS: ${OIDC_CLOCK_SKEW_SECONDS:-5}
FRONTEND_ORIGINS: http://localhost:${FRONTEND_PORT:-15173}
VALKEY_URL: redis://valkey:6379/0
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-lineageweave}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
# Empty by default: every LLM/vision channel stays the Null client
# (dropped, not faked). Set these to a running contextual-orchestrator
# to turn the channels on. Provider credentials use LLM_GATEWAY_API_URL /
Expand Down
6 changes: 5 additions & 1 deletion docker/contextual-orchestrator/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/7df051ac2b929e5910071ac1848d0447c5d6744e.tar.gz /tmp/contextual-orchestrator.tar.gz
ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
&& cp -R /tmp/contextual-orchestrator/examples /app/examples \
&& rm -rf /tmp/contextual-orchestrator /tmp/contextual-orchestrator.tar.gz \
&& python -m pip install --no-cache-dir \
'opentelemetry-api>=1.30.0' \
'opentelemetry-sdk>=1.30.0' \
'opentelemetry-exporter-otlp-proto-http>=1.30.0' \
&& useradd --uid 10001 --no-create-home orchestrator

COPY agents.json /app/agents.json
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0083-orchestrator-runtime-commit-pin.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ multi-agent.
## Decision

`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
commit `7df051ac2b929e5910071ac1848d0447c5d6744e`. The pin remains explicit
commit `1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.

Expand Down
60 changes: 60 additions & 0 deletions docs/adr/0121-buyer-safe-image-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# ADR 0121: Keep Internal Image Instructions Out of Buyer Evidence

## Status

Accepted

## Context

VISION analysis may receive or inherit an internal instruction such as
`This post is an image. Ask questions to read its text.` or its Korean
equivalent. That text is an agent instruction, not a caption describing the
source image. Persisted legacy content can still contain it even after the
prompt is corrected.

## Decision

At the image and visual-region evidence boundary, normalize captions and suppress captions that
match the known internal instruction forms. Apply the same rule when creating
LLM/embedding placeholders and when rendering the buyer-facing post body.
Retain the original image, OCR text, tags, region coordinates, and provenance;
only the non-evidence caption is removed. If no useful caption remains, show
the image and available evidence without inventing a description.

The instruction matcher uses concrete imperative phrases for Korean text
extraction guidance rather than ordinary words such as `텍스트` or `질문`, so
legitimate captions that describe an image containing text remain evidence.

The configured Vision destination is also a trust boundary. The client rejects
credential-bearing URLs, invalid ports, loopback/private/link-local/reserved
IP literals, and known local or cloud-metadata hostnames even when local HTTP
is explicitly enabled. Compose uses the service name `orchestrator` for its
local HTTP route; a caller cannot opt into an internal IP destination by
setting `allow_insecure_http`.

Vision parse failures and unexpected provider failures are also trust-boundary
events. Their raw response or exception text is never returned in an API
payload or persisted as post-content detail; the product exposes a stable
unavailable message and schedules a retry where applicable.

## Consequences

- Buyer screens cannot expose the analysis agent's instruction as post content.
- Existing persisted image rows are safe immediately; re-ingestion is not
required merely to hide the legacy caption.
- OCR, region evidence, and semantic search remain available.
- New provider-specific instruction variants require an explicit, reviewed
pattern and a regression test rather than a broad caption guess.
- A malformed or attacker-controlled Vision endpoint fails closed at client
construction, before an API key or image payload is sent.
- A malformed provider response cannot disclose gateway diagnostics through a
buyer-facing error or durable ingestion record.

## References — APA 7th

MITRE. (2026). *CWE-209: Generation of error message containing sensitive
information*. https://cwe.mitre.org/data/definitions/209.html

National Institute of Standards and Technology. (2020). *Security and privacy
controls for information systems and organizations* (NIST Special Publication
800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5
Loading