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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ BACKEND_PORT=18420
ORCHESTRATOR_BASE_URL=
ORCHESTRATOR_API_KEY=
VISION_MODEL=

# Optional. Empty = naruon mailbox port is unavailable (NaruonNotAvailable,
# never an invented thread). Point at a running naruon /api/emails host
# to list published inbox subjects (ADR 0020).
NARUON_BASE_URL=
NARUON_BEARER=
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ a statistic, never a title, name, or id.
This repo depends on real ContextualWisdomLab-org packages rather than
reimplementing them:

- [naruon](https://github.com/ContextualWisdomLab/naruon) for the
mailbox inbox (`naruon_client.py`, published `GET /api/emails`) --
never invent a thread or read naruon tables.
- [ThreadWeave](https://github.com/ContextualWisdomLab/ThreadWeave) for
tree assembly (`reconstruct.py`'s `_walk`/`thread_messages` calls).
- [RankWeave](https://github.com/ContextualWisdomLab/RankWeave) for
Expand All @@ -53,7 +56,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. `NaruonClient` fails closed with
`NaruonNotAvailable` and an empty thread list -- never an invented
mailbox subject. 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
5 changes: 5 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,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.
- **naruon is a mailbox wire contract, not a table read.**
`naruon_client.py`'s default transport raises `NaruonNotAvailable`.
`GET /api/mailbox` then returns `naruon_not_available` and an empty
thread list. Message bodies are never projected. See ADR 0020.


## Standards and citations

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.d/0.80.0-naruon-mailbox-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 0.80.0 — Fail-closed naruon mailbox

## Added

- Home Mailbox panel lists naruon inbox threads through `NaruonClient`.
After login with naruon HTTP down, Demo Analyst sees
**Mailbox · naruon not available**. An accepted transport lists the
published thread subject. Seed never invents a message.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ 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.80.0] - 2026-08-17

### Added

- Home Mailbox panel lists naruon inbox threads through `NaruonClient`.
After login with naruon HTTP down, Demo Analyst sees
**Mailbox · naruon not available**. An accepted transport lists the
published thread subject. Seed never invents a message.

## [0.75.0] - 2026-08-17

### Added
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,13 @@ curl http://localhost:18420/healthz
`GET /api/posts/{post_id}/keymen`, `GET /api/keymen/{person_id}/related`,
`GET /api/posts/{post_id}/affiliate-tree`,
`GET /api/posts/{post_id}/voc-evidence`,
`GET /api/mailbox`,
and `POST /api/posts/{post_id}/extract-keymen`
require a real bearer token (RBAC: the account's role must grant
`post_read`; ABAC: a private post is only visible to accounts affiliated
with its owning corporate entity -- `backend/app/main.py`). A Keyman who
with its owning corporate entity -- `backend/app/main.py`). `GET /api/mailbox`
returns `naruon_not_available` when naruon HTTP is down and never invents
a thread (ADR 0020). A Keyman who
is only mentioned on a post the account cannot see is 403, same deny
path. `backend/tests/test_api.py` proves both the allow and the deny
path against a live Keycloak + throwaway Postgres database, including
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,11 @@ class Settings:
# RankWeaveNotAvailable -- never invent a fused score. Default false
# uses the in-process library already required by reconstruct.py.
rankweave_disabled: bool
# naruon mailbox control plane (ADR 0020). Empty = fail-closed
# NaruonNotAvailable -- never invent a thread.
naruon_base_url: str
naruon_bearer: str


@property
def keycloak_jwks_uri(self) -> str:
Expand Down Expand Up @@ -88,4 +93,7 @@ def load_settings() -> Settings:
.strip()
.lower()
in {"1", "true", "yes", "on"},
naruon_base_url=os.environ.get("NARUON_BASE_URL", ""),
naruon_bearer=os.environ.get("NARUON_BEARER", ""),

)
25 changes: 25 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
ContextualOrchestratorKeymanExtractionClient,
NullKeymanExtractionClient,
)
from lineageweave.naruon_client import build_naruon_client
from lineageweave.post_chat import (
ContextualOrchestratorPostChatClient,
NullPostChatClient,
Expand Down Expand Up @@ -242,6 +243,16 @@ def _rankweave_client():
return build_rankweave_client(disabled=load_settings().rankweave_disabled)


def _naruon_client():
"""Live naruon inbox client when configured; otherwise fail-closed."""
settings = load_settings()
return build_naruon_client(
base_url=settings.naruon_base_url,
bearer=settings.naruon_bearer,
)



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 +1161,17 @@ async def read_rankings(
return _rankweave_client().as_api_payload(
posts, can_see_post=lambda _row: True
)


@app.get("/api/mailbox")
async def read_mailbox(
account: CurrentAccount = Depends(get_current_account),
) -> dict[str, Any]:
"""naruon inbox projection. Fail-closed when the port is down (ADR 0020).

Never invents a thread or a message body. Click-through to a
``source_post`` is a later mapping slice.
"""
_require_post_read(account)
return _naruon_client().as_api_payload()

12 changes: 12 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,18 @@ def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> No
assert "post_read" in body["permission_codes"]


def test_mailbox_fails_closed_when_naruon_is_unconfigured(client, demo_analyst_token) -> None:
os.environ.pop("NARUON_BASE_URL", None)
os.environ.pop("NARUON_BEARER", None)
response = client.get("/api/mailbox", headers={"Authorization": f"Bearer {demo_analyst_token}"})
assert response.status_code == 200
body = response.json()
assert body["port"] == "naruon"
assert body["status"] == "unavailable"
assert body["status_reason"] == "naruon_not_available"
assert body["threads"] == []


def test_post_list_includes_public_and_own_corp_but_excludes_other_corp(client, demo_analyst_token, seeded_db) -> None:
response = client.get("/api/posts", headers={"Authorization": f"Bearer {demo_analyst_token}"})
assert response.status_code == 200
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ services:
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-}
VISION_MODEL: ${VISION_MODEL:-}
SEARXNG_BASE_URL: http://searxng:8080
# Empty by default: mailbox port stays fail-closed (ADR 0020).
NARUON_BASE_URL: ${NARUON_BASE_URL:-}
NARUON_BEARER: ${NARUON_BEARER:-}
ports:
- "${BACKEND_PORT:-18420}:8000"
depends_on:
Expand Down
48 changes: 48 additions & 0 deletions docs/adr/0020-naruon-mailbox-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# ADR 0020 — Fail-closed naruon mailbox port

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

## Context

LineageWeave reconstructs lineage from records that already exist
somewhere else. The org mailbox control plane is
[naruon](https://github.com/ContextualWisdomLab/naruon). naruon is not
an SMTP/IMAP host; it exposes a signed inbox envelope at
`GET /api/emails`. Until this slice, Demo Analyst had no mailbox
surface: a down naruon port was silent, and nothing stopped a later
writer from inventing a "Quarterly plan" thread.

TEPP already owns the naruon→TEPP analysis-run interchange
(`docs/connectors/naruon-artifact-consumer.md` in TEPP). This ADR does
not reimplement that interchange, invent a second analysis-run
registry, or bind the demo IdP to production Keyverse.

## Decision

1. Consume naruon only through `NaruonClient` and the published
`GET /api/emails` envelope. Never read naruon tables.
2. The default transport raises `NaruonNotAvailable`. HTTP 4xx/5xx,
timeout, network, non-https, and an unknown envelope fail closed.
3. Project `thread_id`, `subject`, and optional `reply_count` only.
Message bodies, raw ids, and provider credentials are not copied.
4. `GET /api/mailbox` (post_read) returns `unavailable` +
`naruon_not_available` + empty `threads` when the port is down.
Seed probes the same client and never inserts an invented email.
5. After login, the home Mailbox panel names that status. An accepted
thread lists its subject; click does not invent a `source_post`.

## Consequences

Demo Analyst sees **Mailbox · naruon not available** after `make seed`
when `NARUON_BASE_URL` is empty. A live naruon transport can list the
published Quarterly plan fixture without fabricating a post. Later
mapping of mailbox threads onto lineage posts is a separate slice.

## References

Contextual Wisdom Lab. (2026). *Naruon AI email workspace* [Software
documentation]. https://github.com/ContextualWisdomLab/naruon

Contextual Wisdom Lab. (2026). *naruon artifact consumer* [Connector
contract]. https://github.com/ContextualWisdomLab/TEPP/blob/main/docs/connectors/naruon-artifact-consumer.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.80.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
53 changes: 52 additions & 1 deletion 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;
}[];
};
mailbox?: {
status: "accepted" | "unavailable";
status_reason: string | null;
threads: { thread_id: string; subject: string; reply_count?: number }[];
};
chatUnavailable?: boolean;
searchUnavailable?: boolean;
verificationEvidenceUrl?: string | null;
Expand Down Expand Up @@ -178,6 +183,22 @@ describe("App, authenticated", () => {
jsonResponse({ post_id: "post-1", has_commitment: true, ticket }),
);
}
if (url.endsWith("/api/mailbox")) {
const mailbox = options?.mailbox ?? {
port: "naruon",
status: "unavailable" as const,
status_reason: "naruon_not_available",
threads: [],
};
return Promise.resolve(
jsonResponse({
port: "naruon",
status: mailbox.status,
status_reason: mailbox.status_reason,
threads: mailbox.threads,
}),
);
}
if (url.endsWith("/api/calendar")) {
return Promise.resolve(
jsonResponse({
Expand Down Expand Up @@ -1307,7 +1328,37 @@ describe("App, authenticated", () => {
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});

it("shows upcoming commitments on the home page calendar and opens the post on click", async () => {
it("names naruon unavailability on the home mailbox instead of inventing a thread", async () => {
stubBackend();
render(<App />);

expect(await screen.findByText("Mailbox · naruon not available")).toBeInTheDocument();
expect(screen.queryByText("Quarterly plan")).not.toBeInTheDocument();
});

it("lists an accepted naruon thread subject without inventing a post", async () => {
stubBackend({
mailbox: {
status: "accepted",
status_reason: null,
threads: [
{
thread_id: "[email protected]",
subject: "Quarterly plan",
reply_count: 3,
},
],
},
});
render(<App />);

expect(await screen.findByText("Quarterly plan")).toBeInTheDocument();
expect(screen.getByText("Mailbox · naruon")).toBeInTheDocument();
expect(screen.getByText("3 replies")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /quarterly plan/i })).not.toBeInTheDocument();
});

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

Expand Down
49 changes: 49 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
fetchCalendar,
fetchLineageGraph,
fetchMe,
fetchMailbox,
fetchPost,
fetchPostActivity,
fetchPostChat,
Expand Down Expand Up @@ -41,6 +42,7 @@ import {
type EvaluationResponse,
type IssueTicket,
type LineageGraph,
type MailboxInbox,
type Keyman,
type LinkedPostRef,
type PostAiSummary,
Expand Down Expand Up @@ -1371,6 +1373,52 @@ function RankingsPanel({
);
}

function MailboxPanel({ accessToken }: { accessToken: string }) {
const [inbox, setInbox] = useState<MailboxInbox | null>(null);
const [error, setError] = useState<string | null>(null);

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

return (
<section className="popup-section lineage-home" aria-label="Mailbox">
<div className="lineage-home-header">
<h2>Mailbox</h2>
{inbox && (
<span className="post-badge">
{inbox.status === "accepted" ? "naruon" : `naruon · ${inbox.status_reason ?? "unavailable"}`}
</span>
)}
</div>
{error && <p className="error">{error}</p>}
{inbox === null && !error && <p>Loading mailbox...</p>}
{inbox && inbox.status === "unavailable" && (
<p className="popup-placeholder">Mailbox · naruon not available</p>
)}
{inbox && inbox.status === "accepted" && inbox.threads.length === 0 && (
<p className="popup-placeholder">No mailbox threads from naruon.</p>
)}
{inbox && inbox.threads.length > 0 && (
<ul className="ticket-list" aria-label="Mailbox threads">
{inbox.threads.map((thread) => (
<li key={thread.thread_id} className="ticket-list-item">
<span className="ticket-title">{thread.subject}</span>
<span className="post-badge">Mailbox · naruon</span>
{thread.reply_count != null && (
<span className="post-badge">{thread.reply_count} replies</span>
)}
</li>
))}
</ul>
)}
</section>
);
}

function CalendarPanel({
accessToken,
onSelectPost,
Expand Down Expand Up @@ -1690,6 +1738,7 @@ function PostList({ accessToken }: { accessToken: string }) {
return (
<>
<RankingsPanel accessToken={accessToken} onSelectPost={setSelectedPostId} />
<MailboxPanel accessToken={accessToken} />
<CalendarPanel accessToken={accessToken} onSelectPost={setSelectedPostId} />
<ReportsPanel accessToken={accessToken} canRebuild={canRebuild} onSelectPost={setSelectedPostId} />
<section className="popup-section lineage-home">
Expand Down
Loading
Loading