Skip to content
Open
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,8 @@ BACKEND_PORT=18420
ORCHESTRATOR_BASE_URL=
ORCHESTRATOR_API_KEY=
VISION_MODEL=

# Optional. Empty = Keyverse identity port is unavailable
# (KeyverseNotAvailable, never an invented issuer or account). Point at
# a running Keyverse admin-service host to probe GET /healthz (ADR 0025).
KEYVERSE_BASE_URL=
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ reimplementing them:
- [TEPP](https://github.com/ContextualWisdomLab/TEPP)'s published wire
contract for calibrated measurement (`tepp_client.py`) -- never
reimplement TEPP's model here.
- [Keyverse](https://github.com/ContextualWisdomLab/keyverse) for the
ecosystem IdP readiness port (`keyverse_client.py`) -- never invent
an issuer, account, or token. Demo login stays on synthetic Keycloak.
- [contextual-orchestrator](https://github.com/ContextualWisdomLab/contextual-orchestrator)
for LLM adjudication (`adjudication_client.py`) -- never call a raw LLM
API directly from this repo; go through the orchestrator so
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) |
| `keyverse_client.py` | Fail-closed Keyverse identity port (`GET /healthz`; never invent an issuer, account, or token) |
| `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.
- **Keyverse is a published healthz, not a second login.**
`keyverse_client.py`'s default transport raises
`KeyverseNotAvailable`. `GET /api/identity` then returns
`keyverse_not_available` and `ready=false`. Demo login stays on
the synthetic Keycloak realm. See ADR 0025.

## Standards and citations

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.d/0.76.0-keyverse-identity-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# 0.76.0 — Fail-closed Keyverse identity

## Added

- Home Identity panel probes Keyverse through `KeyverseClient`.
After login with the port unconfigured or healthz down, Demo
Analyst sees **Identity · Keyverse not available**. An accepted
probe names readiness only. Never invent an issuer, account, or
token.
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.76.0] - 2026-08-17

### Added

- Home Identity panel probes Keyverse through `KeyverseClient`
(ADR 0025). After login with the port unconfigured or healthz
down, Demo Analyst sees **Identity · Keyverse not available**.
An accepted probe names readiness only. Never invent an issuer,
account, or token.

## [0.75.0] - 2026-08-17

### Added
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ The optional LLM-adjudication channel calls
[ThreadWeave](https://github.com/ContextualWisdomLab/ThreadWeave) (JWZ
message threading) and channel fusion reuses
[RankWeave](https://github.com/ContextualWisdomLab/RankWeave) (weighted
score fusion for reconstruction and the fail-closed Rankings port) -- both real dependencies, not reimplemented here.
score fusion for reconstruction and the fail-closed Rankings port) --
both real dependencies, not reimplemented here. The fail-closed Identity
port probes [Keyverse](https://github.com/ContextualWisdomLab/keyverse)
healthz and never invents an issuer or account -- demo login stays on
synthetic Keycloak.

## Run it

Expand Down
4 changes: 4 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ class Settings:
# RankWeaveNotAvailable -- never invent a fused score. Default false
# uses the in-process library already required by reconstruct.py.
rankweave_disabled: bool
# Keyverse identity port (ADR 0025). Empty = fail-closed
# KeyverseNotAvailable -- never invent an issuer or account.
keyverse_base_url: str

@property
def keycloak_jwks_uri(self) -> str:
Expand Down Expand Up @@ -88,4 +91,5 @@ def load_settings() -> Settings:
.strip()
.lower()
in {"1", "true", "yes", "on"},
keyverse_base_url=os.environ.get("KEYVERSE_BASE_URL", ""),
)
17 changes: 17 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.keyverse_client import build_keyverse_client

from backend.app.activity_stream import (
create_valkey_client,
Expand Down Expand Up @@ -242,6 +243,11 @@ def _rankweave_client():
return build_rankweave_client(disabled=load_settings().rankweave_disabled)


def _keyverse_client():
"""Live Keyverse healthz client when configured; otherwise fail-closed."""
return build_keyverse_client(base_url=load_settings().keyverse_base_url)


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 +1156,14 @@ async def read_rankings(
return _rankweave_client().as_api_payload(
posts, can_see_post=lambda _row: True
)
@app.get("/api/identity")
async def read_identity(
account: CurrentAccount = Depends(get_current_account),
) -> dict[str, Any]:
"""Keyverse readiness (ADR 0025).

Never invents an issuer, account, or token. Fail-closed when
Keyverse is unconfigured or healthz is down.
"""
_require_post_read(account)
return _keyverse_client().as_api_payload()
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_keyverse_base_url_defaults_empty(monkeypatch) -> None:
monkeypatch.delenv("KEYVERSE_BASE_URL", raising=False)
assert load_settings().keyverse_base_url == ""


def test_keyverse_base_url_is_opt_in(monkeypatch) -> None:
monkeypatch.setenv("KEYVERSE_BASE_URL", "https://keyverse.example")
assert load_settings().keyverse_base_url == "https://keyverse.example"
51 changes: 51 additions & 0 deletions docs/adr/0025-keyverse-identity-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# ADR 0025 — Fail-closed Keyverse identity port

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

## Context

LineageWeave's demo login is a local Keycloak realm with synthetic
accounts (ADR 0001). The organization's production IdP is
[Keyverse](https://github.com/ContextualWisdomLab/keyverse): passwordless
OIDC on Keycloak plus an account-unification admin service that
publishes `GET /healthz` as `{status: "ok"}`. Until this slice, Demo
Analyst had no buyer-facing identity-port status: a down Keyverse was
silent, and nothing stopped a later writer from inventing an issuer,
account, or corp code.

This ADR does not replace the synthetic demo Keycloak login, does not
register LineageWeave as a production relying party, and does not bind
demo tokens to a production Keyverse tenant.

## Decision

1. Consume Keyverse only through `KeyverseClient` and the published
`GET /healthz` envelope. Never read Keyverse tables. Never copy an
issuer, account, token, or client registration.
2. The default transport raises `KeyverseNotAvailable`. HTTP 4xx/5xx,
timeout, network, non-https, and an unknown envelope fail closed.
3. Project only `ready=true` when `status` is exactly `ok`. Extra
healthz fields are dropped.
4. `GET /api/identity` (`post_read`) returns `unavailable` +
`keyverse_not_available` + `ready=false` when the port is down.
5. After login, Identity sits above Calendar. Unavailable copy is
**Identity · Keyverse not available**. An accepted probe names
readiness only — click does not invent a login.

## Consequences

`KEYVERSE_BASE_URL` empty keeps the fail-closed transport. Demo login
stays on the synthetic Keycloak realm. Rankings stay on ADR 0024 /
#220. Mailbox stays on ADR 0020 / #217. Conversations stay on ADR 0021
/ #219. TEPP stays on #214. Registering LineageWeave as a Keyverse RP
is a later slice.

## References

Contextual Wisdom Lab. (2026). *cwl-idp — ecosystem central IdP*
[Software documentation]. https://github.com/ContextualWisdomLab/keyverse

Contextual Wisdom Lab. (2026). *Relying-party onboarding* [Keyverse
documentation].
https://github.com/ContextualWisdomLab/keyverse/blob/main/docs/rp-onboarding.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.76.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ describe("App, authenticated", () => {
fused_rank: number;
}[];
};
identity?: {
status?: "accepted" | "unavailable";
status_reason?: string | null;
ready?: boolean;
};
chatUnavailable?: boolean;
searchUnavailable?: boolean;
verificationEvidenceUrl?: string | null;
Expand Down Expand Up @@ -178,6 +183,21 @@ describe("App, authenticated", () => {
jsonResponse({ post_id: "post-1", has_commitment: true, ticket }),
);
}
if (url.endsWith("/api/identity")) {
const identity = options?.identity ?? {
status: "unavailable" as const,
status_reason: "keyverse_not_available",
ready: false,
};
return Promise.resolve(
jsonResponse({
port: "keyverse",
status: identity.status,
status_reason: identity.status_reason,
ready: identity.ready ?? false,
}),
);
}
if (url.endsWith("/api/calendar")) {
return Promise.resolve(
jsonResponse({
Expand Down Expand Up @@ -1307,6 +1327,30 @@ describe("App, authenticated", () => {
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});

it("names Keyverse unavailability on home identity instead of inventing an account", async () => {
stubBackend();
render(<App />);

expect(await screen.findByText("Identity · Keyverse not available")).toBeInTheDocument();
expect(screen.queryByText("Keyverse admin service is ready.")).not.toBeInTheDocument();
});

it("names accepted Keyverse readiness without inventing an issuer", async () => {
stubBackend({
identity: {
status: "accepted",
status_reason: null,
ready: true,
},
});
render(<App />);

expect(await screen.findByText("Keyverse admin service is ready.")).toBeInTheDocument();
expect(screen.getByText("Identity · keyverse")).toBeInTheDocument();
expect(screen.queryByText(/issuer/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /keyverse/i })).not.toBeInTheDocument();
});

it("shows upcoming commitments on the home page calendar and opens the post on click", async () => {
stubBackend();
render(<App />);
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
fetchPeriodReportIndex,
fetchPeriodReports,
fetchPosts,
fetchIdentity,
fetchRankings,
fetchRelatedEntity,
fetchRelatedKeymen,
Expand All @@ -50,6 +51,7 @@ import {
type PeriodReports,
type PostLineage,
type PostSummary,
type IdentityStatus,
type RankingList,
type RelatedNode,
type VocEvidence,
Expand Down Expand Up @@ -1371,6 +1373,44 @@ function RankingsPanel({
);
}

function IdentityPanel({ accessToken }: { accessToken: string }) {
const [identity, setIdentity] = useState<IdentityStatus | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
setError(null);
fetchIdentity(accessToken)
.then(setIdentity)
.catch((err) => setError(String(err)));
}, [accessToken]);

return (
<section className="popup-section lineage-home" aria-label="Identity">
<div className="lineage-home-header">
<h2>Identity</h2>
{identity && (
<span className="post-badge">
{identity.status === "accepted"
? "keyverse"
: `keyverse · ${identity.status_reason ?? "unavailable"}`}
</span>
)}
</div>
{error && <p className="error">{error}</p>}
{identity === null && !error && <p>Loading identity...</p>}
{identity && identity.status === "unavailable" && (
<p className="popup-placeholder">Identity · Keyverse not available</p>
)}
{identity && identity.status === "accepted" && identity.ready && (
<p>
<span className="ticket-title">Keyverse admin service is ready.</span>{" "}
<span className="post-badge">Identity · keyverse</span>
</p>
)}
</section>
);
}

function CalendarPanel({
accessToken,
onSelectPost,
Expand Down Expand Up @@ -1690,6 +1730,7 @@ function PostList({ accessToken }: { accessToken: string }) {
return (
<>
<RankingsPanel accessToken={accessToken} onSelectPost={setSelectedPostId} />
<IdentityPanel accessToken={accessToken} />
<CalendarPanel accessToken={accessToken} onSelectPost={setSelectedPostId} />
<ReportsPanel accessToken={accessToken} canRebuild={canRebuild} onSelectPost={setSelectedPostId} />
<section className="popup-section lineage-home">
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,3 +515,14 @@ export interface RankingList {
export function fetchRankings(accessToken: string): Promise<RankingList> {
return backendFetch("/api/rankings", accessToken);
}

export interface IdentityStatus {
port: string;
status: "accepted" | "unavailable";
status_reason: string | null;
ready: boolean;
}

export function fetchIdentity(accessToken: string): Promise<IdentityStatus> {
return backendFetch("/api/identity", accessToken);
}
2 changes: 1 addition & 1 deletion lineageweave/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@
"sentence_excerpts",
]

__version__ = "0.75.0"
__version__ = "0.76.0"
Loading
Loading