Skip to content
Closed
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
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ This repo depends on real ContextualWisdomLab-org packages rather than
reimplementing them:

- [ThreadWeave](https://github.com/ContextualWisdomLab/ThreadWeave) for
tree assembly (`reconstruct.py`'s `_walk`/`thread_messages` calls).
tree assembly (`reconstruct.py`'s `_walk`/`thread_messages` calls)
and the buyer-facing Conversations port (`threadweave_client.py`) --
never invent a parent.
- [RankWeave](https://github.com/ContextualWisdomLab/RankWeave) for
multi-channel score fusion (`weighted_convex_fuse` in
`reconstruct.py`) and the buyer-facing Rankings port
Expand All @@ -53,7 +55,9 @@ does it (`gh repo list ContextualWisdomLab`).
must set `available = False` and make their channel dropped +
renormalized (`reconstruct.active_weights`), never silently return a
placeholder score, invented Keyman, guessed relationship, fabricated
summary/chat, or invented commitment. A missing signal and a
summary/chat, or invented commitment. `ThreadWeaveClient` fails closed
with `ThreadWeaveNotAvailable` and an empty conversation list -- never
an invented parent. A missing signal and a
confidently-negative signal are different things. Keyman extraction,
entity-relationship classification, post summary, in-popup chat, and
commitment derivation go through contextual-orchestrator the same way
Expand Down
6 changes: 6 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ flowchart LR
| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl) |
| `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport |
| `rankweave_client.py` | Fail-closed RankWeave ranking port (`weighted_reciprocal_rank_fuse` in-process; never invent a fused score or a theta) |
| `threadweave_client.py` | Fail-closed ThreadWeave conversation port (`thread_messages` in-process; never invent a parent) |
| `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread |
| `lineage_persistence.py` | Flattens reconstruct trees into `post_lineage_edge` row specs (parent, child, fused_score) |
| `knowledge_graph.py` | Random-walk-with-restart relevance + per-node adaptive related-node cutoff (Tong et al., 2006) -- pure graph math, no Postgres |
Expand Down Expand Up @@ -123,6 +124,11 @@ flowchart LR
`RankWeaveNotAvailable`. `GET /api/rankings` then returns
`rankweave_not_available` and an empty ranking list. Hidden posts
are omitted from every channel. See ADR 0024.
- **ThreadWeave is an in-process library, not an HTTP host.**
`threadweave_client.py`'s default transport raises
`ThreadWeaveNotAvailable`. `GET /api/conversations` then returns
`threadweave_not_available` and an empty conversation list. Hidden
parents are omitted so the child becomes a root. See ADR 0021.

## Standards and citations

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.d/0.81.0-threadweave-conversations-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# 0.81.0 — Fail-closed ThreadWeave conversations

## Added

- Home Conversations panel threads visible posts through
`ThreadWeaveClient`. After login with the port disabled or the
library missing, Demo Analyst sees **Conversations · ThreadWeave not
available**. An accepted tree lists the root title; click opens that
post. A hidden parent is omitted. Never invent a parent.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.81.0] - 2026-08-17

### Added

- Home Conversations panel threads visible posts through ThreadWeave.
After login with the port disabled or the library missing, Demo
Analyst sees **Conversations · ThreadWeave not available**. An
accepted tree lists the root title; click opens that post. A hidden
parent is omitted. Never invent a parent.

## [0.75.0] - 2026-08-17

### Added
Expand Down
8 changes: 8 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ class Settings:
# RankWeaveNotAvailable -- never invent a fused score. Default false
# uses the in-process library already required by reconstruct.py.
rankweave_disabled: bool
# ThreadWeave conversation port (ADR 0021). True = fail-closed
# ThreadWeaveNotAvailable -- never invent a parent. Default false
# uses the in-process library already required by reconstruct.py.
threadweave_disabled: bool

@property
def keycloak_jwks_uri(self) -> str:
Expand Down Expand Up @@ -88,4 +92,8 @@ def load_settings() -> Settings:
.strip()
.lower()
in {"1", "true", "yes", "on"},
threadweave_disabled=os.environ.get("THREADWEAVE_DISABLED", "")
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
.strip()
.lower()
in {"1", "true", "yes", "on"},
)
34 changes: 34 additions & 0 deletions backend/app/conversation_ingestion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Load visible posts + visible-only lineage edges as ThreadWeave messages.

A hidden parent is omitted from ``references`` so the visible child
becomes a JWZ root. This module never invents a parent id.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Callable, Mapping

from lineageweave.threadweave_client import conversation_messages_from_rows

if TYPE_CHECKING:
import asyncpg

__all__ = [
"conversation_messages_from_rows",
"load_visible_conversation_messages",
]


async def load_visible_conversation_messages(
conn: "asyncpg.Connection",
can_see_post: Callable[[Mapping[str, Any]], bool],
) -> list[dict[str, Any]]:
"""Read ``source_post`` / ``post_lineage_edge`` and drop hidden parents."""
posts = await conn.fetch(
"select post_id, post_title, visibility_code, corporate_entity_id "
"from source_post"
)
edges = await conn.fetch(
"select parent_post_id, child_post_id from post_lineage_edge"
)
return conversation_messages_from_rows(posts, edges, can_see_post)
24 changes: 24 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
from lineageweave.post_summary import ContextualOrchestratorPostSummaryClient, NullPostSummaryClient
from lineageweave.relation_verification import NullRelationVerificationClient, SearxngRelationVerificationClient
from lineageweave.rankweave_client import build_rankweave_client
from lineageweave.threadweave_client import build_threadweave_client

from backend.app.activity_stream import (
create_valkey_client,
Expand All @@ -67,6 +68,7 @@
from backend.app.affiliate_tree_ingestion import fetch_affiliate_forest, fetch_voc_evidence
from backend.app.auth import CurrentAccount, get_current_account
from backend.app.config import load_settings
from backend.app.conversation_ingestion import load_visible_conversation_messages
from backend.app.db import create_pool, get_pool
from backend.app.entity_relationship_ingestion import (
fetch_post_counterparties,
Expand Down Expand Up @@ -242,6 +244,11 @@ def _rankweave_client():
return build_rankweave_client(disabled=load_settings().rankweave_disabled)


def _threadweave_client():
"""In-process ThreadWeave when enabled; otherwise fail-closed."""
return build_threadweave_client(disabled=load_settings().threadweave_disabled)


def _can_see_post(account: CurrentAccount, post: asyncpg.Record) -> bool:
"""ABAC: public rows are visible; private rows require same-corp affiliation."""
if post["visibility_code"] == "public":
Expand Down Expand Up @@ -1150,3 +1157,20 @@ async def read_rankings(
return _rankweave_client().as_api_payload(
posts, can_see_post=lambda _row: True
)
@app.get("/api/conversations")
async def read_conversations(
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""ThreadWeave forest of ABAC-visible posts (ADR 0021).

Hidden parents are omitted so the child becomes a root. Never
invents a parent. Fail-closed when ThreadWeave is disabled or
the library is missing.
"""
_require_post_read(account)
async with pool.acquire() as conn:
messages = await load_visible_conversation_messages(
conn, lambda row: _can_see_post(account, row)
)
return _threadweave_client().as_api_payload(messages)
8 changes: 8 additions & 0 deletions backend/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@ def test_rankweave_disabled_defaults_off(monkeypatch) -> None:
def test_rankweave_disabled_flag_is_opt_in(monkeypatch) -> None:
monkeypatch.setenv("RANKWEAVE_DISABLED", "1")
assert load_settings().rankweave_disabled is True
def test_threadweave_disabled_defaults_off(monkeypatch) -> None:
monkeypatch.delenv("THREADWEAVE_DISABLED", raising=False)
assert load_settings().threadweave_disabled is False


def test_threadweave_disabled_flag_is_opt_in(monkeypatch) -> None:
monkeypatch.setenv("THREADWEAVE_DISABLED", "1")
assert load_settings().threadweave_disabled is True
55 changes: 55 additions & 0 deletions docs/adr/0021-threadweave-conversation-port.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# ADR 0021 — Fail-closed ThreadWeave conversation port

**Decision status:** Accepted
**Date:** 2026-08-17

## Context

LineageWeave already calls ThreadWeave inside `reconstruct.py` to
assemble RankWeave parent choices into trees. Demo Analyst had no
buyer-facing Conversations surface over those persisted edges.
ThreadWeave is an in-process library
([API contract](https://github.com/ContextualWisdomLab/ThreadWeave/blob/main/docs/API_CONTRACT.md)):
it does not define HTTP, a mailbox host, or authentication. A missing
package, a disabled port, or a JWZ dummy for a hidden parent must not
become an invented conversation.

This ADR does not replace `reconstruct.py`, does not read naruon
tables, and does not bind the demo IdP to production Keyverse. ADR
0020 already reserved later mapping of mailbox threads onto lineage
posts; this slice threads *visible* `source_post` rows only.

## Decision

1. Consume ThreadWeave only through `ThreadWeaveClient`. The default
transport raises `ThreadWeaveNotAvailable`. `build_threadweave_client
(disabled=False)` uses `LibraryThreadWeaveTransport`, which imports
`thread_messages` inside the call so a missing package fail-closes.
2. `GET /api/conversations` (`post_read`) loads ABAC-visible posts and
visible-only `post_lineage_edge` rows as JWZ `references`. A hidden
parent is omitted; the child becomes a root. Never invent a parent.
3. Dummy JWZ containers (referenced-but-missing ids) lift their
children instead of projecting an untitled parent.
4. After login, Conversations sits above Calendar. Unavailable copy is
**Conversations · ThreadWeave not available**. An accepted tree
lists the root title; click opens that `source_post`.

## Consequences

`THREADWEAVE_DISABLED=1` keeps the fail-closed transport. The default
seeded stack uses the in-process library and lists the designed A-100
fork when those posts are visible. Mailbox stays on ADR 0020 / #217.
Leftover pairs stay on #211. TEPP stays on #214.

## References

Crispin, M., & Murchison, K. (2008). *Internet Message Access Protocol
— SORT and THREAD extensions* (RFC 5256). IETF.
https://doi.org/10.17487/RFC5256

Zawinski, J. (2002). *Message threading* [Technical note].
https://www.jwz.org/doc/threading.html

Contextual Wisdom Lab. (2026). *ThreadWeave public API and version
contract* [Software documentation].
https://github.com/ContextualWisdomLab/ThreadWeave/blob/main/docs/API_CONTRACT.md
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.75.0",
"version": "0.81.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
67 changes: 67 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ describe("App, authenticated", () => {
fused_rank: number;
}[];
};
conversations?: {
status: "accepted" | "unavailable";
status_reason: string | null;
conversations: {
post_id: string;
post_title: string;
children?: { post_id: string; post_title: string }[];
}[];
};
chatUnavailable?: boolean;
searchUnavailable?: boolean;
verificationEvidenceUrl?: string | null;
Expand Down Expand Up @@ -178,6 +187,22 @@ describe("App, authenticated", () => {
jsonResponse({ post_id: "post-1", has_commitment: true, ticket }),
);
}
if (url.endsWith("/api/conversations")) {
const conversations = options?.conversations ?? {
port: "threadweave",
status: "unavailable" as const,
status_reason: "threadweave_not_available",
conversations: [],
};
return Promise.resolve(
jsonResponse({
port: "threadweave",
status: conversations.status,
status_reason: conversations.status_reason,
conversations: conversations.conversations,
}),
);
}
if (url.endsWith("/api/calendar")) {
return Promise.resolve(
jsonResponse({
Expand Down Expand Up @@ -1307,6 +1332,48 @@ describe("App, authenticated", () => {
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});

it("names ThreadWeave unavailability on home conversations instead of inventing a parent", async () => {
stubBackend();
render(<App />);

expect(await screen.findByText("Conversations · ThreadWeave not available")).toBeInTheDocument();
expect(screen.queryByText("Pricing renegotiation: revised quote sent")).not.toBeInTheDocument();
});

it("opens an accepted conversation tree root without inventing a parent", async () => {
stubBackend({
conversations: {
status: "accepted",
status_reason: null,
conversations: [
{
post_id: "post-1",
post_title: "Public post",
children: [
{
post_id: "post-2",
post_title: "Pricing renegotiation: revised quote sent",
},
],
},
],
},
});
render(<App />);

const conversationButton = await screen.findByRole("button", {
name: /open conversation: public post/i,
});
expect(conversationButton).toHaveTextContent("Public post");
expect(conversationButton).toHaveTextContent("Conversations · threadweave");
expect(conversationButton).toHaveTextContent("1 reply");
expect(screen.queryByRole("button", { name: /open conversation: private parent/i })).not.toBeInTheDocument();

await userEvent.click(conversationButton);

await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("shows upcoming commitments on the home page calendar and opens the post on click", async () => {
stubBackend();
render(<App />);
Expand Down
Loading
Loading