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

# Optional. Empty = TEPP submit stays fail-closed (no invented theta).
# Point at a live TEPP HTTP root to POST /v1/analysis-runs.
TEPP_BASE_URL=
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,10 @@ pnpm run lint && pnpm run test && pnpm run build
Do not weaken, skip, or `continue-on-error` a failing check -- fix the
underlying cause or, for a genuine false positive in a third-party scanner,
add a narrow, documented suppression referencing the specific finding.

## TEPP measurement

TEPP thetas come only from TEPP. `TeppClient.submit_fail_closed` and
`outbox:tepp` never store a score. Empty `TEPP_BASE_URL` is
`tepp_not_available`, not a guessed number. Do not copy IRT period-report
θ into a TEPP envelope.
14 changes: 7 additions & 7 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ flowchart LR
| `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` |
| `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) |
| `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 |
| `tepp_client.py` | TEPP's published `AnalysisRunRequest` plus HTTP `/v1/analysis-runs` and a fail-closed envelope (ADR 0022). Never invents a theta. |
| `rankweave_client.py` | Fail-closed RankWeave ranking port (`weighted_reciprocal_rank_fuse` in-process; never invent a fused score or a theta) |
| `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) |
Expand Down Expand Up @@ -112,12 +112,12 @@ flowchart LR
`ponytail`-tagged in `reconstruct.py`: keeps per-group cost `O(n * window)`
instead of `O(n^2)` for large groups; raise it if recall against a labeled
set ever shows true parents falling outside the window.
- **TEPP is a wire contract, not an import.** `tepp_client.py`'s default
transport raises `TeppNotAvailable` rather than silently no-op'ing,
because TEPP has no live HTTP endpoint yet; the shape is validated
(`AnalysisRunRequest.to_json()` mirrors TEPP's published JSON Schema
exactly, `additionalProperties: false` and all) so wiring in a real
transport is additive, not a rewrite.
- **TEPP is a wire contract, not an invented score.** `tepp_client.py`
POSTs the published `AnalysisRunRequest` to
`{TEPP_BASE_URL}/v1/analysis-runs` when that URL is set. Empty or
failing TEPP returns a fail-closed envelope (`tepp_not_available`)
and an `outbox:tepp` Valkey row (ADR 0022 / 0023). The envelope has
no theta.
- **RankWeave is an in-process library, not an HTTP host.**
`rankweave_client.py`'s default transport raises
`RankWeaveNotAvailable`. `GET /api/rankings` then returns
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.d/0.79.0-tepp-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# 0.79.0 — TEPP HTTP + fail-closed outbox

## Added

- Request TEPP measurement from Period reports. Missing TEPP stays
fail-closed on the Valkey outbox. No invented theta.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ 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.79.0] - 2026-08-17

### Added

- TEPP HTTP port and a fail-closed submit envelope (ADR 0022 / 0023).
After `make seed`, Period reports shows the last Valkey outbox
row: TEPP is not available and no score was invented. post_admin
can Request TEPP measurement; LineageWeave POSTs the published
`AnalysisRunRequest` to `TEPP_BASE_URL/v1/analysis-runs` when that
URL is set, otherwise the same fail-closed envelope. Thetas still
come only from TEPP or from `calibrate_period_report` on the IRT
panel -- never copied into the TEPP envelope.

## [0.75.0] - 2026-08-17

### Added
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
# Target TEPP HTTP root (ADR 0022). Empty = crate-only default
# transport; submit stays fail-closed and never invents a theta.
tepp_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"},
tepp_base_url=os.environ.get("TEPP_BASE_URL", ""),
)
57 changes: 57 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from contextlib import asynccontextmanager
from typing import Any
from uuid import uuid4

import asyncpg
import redis.asyncio as redis
Expand Down Expand Up @@ -55,6 +56,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.tepp_client import AnalysisRunRequest, client_from_base_url, submit_fail_closed

from backend.app.activity_stream import (
create_valkey_client,
Expand Down Expand Up @@ -83,6 +85,7 @@
parse_period_code,
rebuild_period_reports,
)
from backend.app.tepp_outbox import list_tepp_outbox, publish_tepp_outbox
from backend.app.relation_verification_ingestion import verify_post_relations
from backend.app.issue_ticket_ingestion import (
create_ticket,
Expand Down Expand Up @@ -1150,3 +1153,57 @@ async def read_rankings(
return _rankweave_client().as_api_payload(
posts, can_see_post=lambda _row: True
)
class TeppAnalysisRunRequest(BaseModel):
snapshot_id: str
knowledge_cutoff: str
output_profile: str = "graphml"
idempotency_key: str | None = None
model_contract_version: str = "v1"


def _tenant_workspace_id(account: CurrentAccount) -> str:
"""Stable tenant identity from an affiliated corp, never a guessed score."""
if account.corporate_entity_ids:
return sorted(account.corporate_entity_ids)[0]
return account.user_account_id


@app.get("/api/tepp/outbox")
async def read_tepp_outbox(
account: CurrentAccount = Depends(get_current_account),
valkey: redis.Redis = Depends(get_valkey),
) -> dict[str, Any]:
"""Recent TEPP submit envelopes. post_read. No theta is stored."""
_require_post_read(account)
events = await list_tepp_outbox(valkey)
return {"events": events}


@app.post("/api/tepp/analysis-runs")
async def submit_tepp_analysis_run(
body: TeppAnalysisRunRequest,
account: CurrentAccount = Depends(get_current_account),
valkey: redis.Redis = Depends(get_valkey),
) -> dict[str, Any]:
"""Submit TEPP's published request. post_admin. Fail-closed, no invented theta."""
_require_post_admin(account)
snapshot_id = body.snapshot_id.strip()
knowledge_cutoff = body.knowledge_cutoff.strip()
output_profile = body.output_profile.strip()
if not snapshot_id or not knowledge_cutoff or not output_profile:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "snapshot_id, knowledge_cutoff, and output_profile are required")
idempotency_key = (body.idempotency_key or "").strip() or str(uuid4())
request = AnalysisRunRequest(
idempotency_key=idempotency_key,
tenant_workspace_id=_tenant_workspace_id(account),
snapshot_id=snapshot_id,
knowledge_cutoff=knowledge_cutoff,
model_contract_version=(body.model_contract_version or "v1").strip() or "v1",
output_profile=output_profile,
)
envelope = submit_fail_closed(client_from_base_url(load_settings().tepp_base_url), request)
event_id = await publish_tepp_outbox(valkey, envelope, account.user_account_id)
payload = envelope.to_json()
payload["event_id"] = event_id
return payload

65 changes: 65 additions & 0 deletions backend/app/tepp_outbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Valkey outbox for TEPP submit envelopes (ADR 0023).

The stream is the durable attempt log -- not a second score table.
Fields are outcome + next action + the published request identity.
No theta is written.
"""

from __future__ import annotations

from typing import Any

from lineageweave.fail_closed import FailClosedEnvelope

TEPP_OUTBOX_STREAM = "outbox:tepp"


def outbox_fields(envelope: FailClosedEnvelope, actor_account_id: str) -> dict[str, str]:
"""String fields Valkey can XADD. Measurement keys are never included."""
request = envelope.request or {}
return {
"channel_code": envelope.channel_code,
"outcome_code": envelope.outcome_code,
"next_action": envelope.next_action,
"actor_account_id": str(actor_account_id),
"idempotency_key": str(request.get("idempotency_key", "")),
"snapshot_id": str(request.get("snapshot_id", "")),
"knowledge_cutoff": str(request.get("knowledge_cutoff", "")),
"tenant_workspace_id": str(request.get("tenant_workspace_id", "")),
}


def publish_tepp_outbox_sync(client: Any, envelope: FailClosedEnvelope, actor_account_id: str) -> str | None:
"""Sync ``XADD`` for ``make seed``. Skips a matching idempotency key."""
fields = outbox_fields(envelope, actor_account_id)
existing = client.xrevrange(TEPP_OUTBOX_STREAM, count=50)
if any(row.get("idempotency_key") == fields["idempotency_key"] for _entry_id, row in existing):
return None
return client.xadd(TEPP_OUTBOX_STREAM, fields, maxlen=1000, approximate=True)


async def publish_tepp_outbox(client: Any, envelope: FailClosedEnvelope, actor_account_id: str) -> str:
"""``XADD`` one fail-closed (or accepted) TEPP attempt."""
return await client.xadd(
TEPP_OUTBOX_STREAM,
outbox_fields(envelope, actor_account_id),
maxlen=1000,
approximate=True,
)


async def list_tepp_outbox(client: Any, count: int = 20) -> list[dict[str, Any]]:
"""Newest first. Payloads are labels and request identity only."""
entries = await client.xrevrange(TEPP_OUTBOX_STREAM, count=count)
return [
{
"event_id": entry_id,
"channel_code": fields.get("channel_code", ""),
"outcome_code": fields.get("outcome_code", ""),
"next_action": fields.get("next_action", ""),
"idempotency_key": fields.get("idempotency_key", ""),
"snapshot_id": fields.get("snapshot_id", ""),
"knowledge_cutoff": fields.get("knowledge_cutoff", ""),
}
for entry_id, fields in entries
]
5 changes: 5 additions & 0 deletions backend/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,8 @@ 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_tepp_base_url_defaults_empty(monkeypatch) -> None:
monkeypatch.delenv("TEPP_BASE_URL", raising=False)
assert load_settings().tepp_base_url == ""
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ services:
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-}
VISION_MODEL: ${VISION_MODEL:-}
SEARXNG_BASE_URL: http://searxng:8080
# Empty = TEPP submit stays fail-closed (ADR 0022). Never invents a theta.
TEPP_BASE_URL: ${TEPP_BASE_URL:-}
ports:
- "${BACKEND_PORT:-18420}:8000"
depends_on:
Expand Down
37 changes: 37 additions & 0 deletions docs/adr/0022-tepp-http-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# ADR 0022 — TEPP HTTP port stays fail-closed

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

## Context

`TeppClient` already builds TEPP's published `AnalysisRunRequest`
(`schemas/analysis_run_request_v1.json`). Protected TEPP main is still
crate-only; the target HTTP resource is `POST /v1/analysis-runs`
(TEPP `docs/API_CONTRACT.md`). LineageWeave must not invent a theta
when that service is unset or unreachable, and must not treat IRT
period-report θ as a TEPP measurement.

## Decision

1. When `TEPP_BASE_URL` is set, POST the published request through
`http_client.post_json` to `{TEPP_BASE_URL}/v1/analysis-runs`.
2. When it is empty, keep the crate-only default transport.
3. Every caller uses `submit_fail_closed`, which returns a
`FailClosedEnvelope` (`accepted` / `tepp_not_available` /
`tepp_transport_failed`) with a customer-actionable `next_action`.
The envelope has no theta field; a provider `theta` key is stripped.
4. `POST /api/tepp/analysis-runs` is `post_admin`. Unavailable is a
200 envelope, not a fabricated score.

## Consequences

- The buyer can request a TEPP run after `make seed` and read why
none exists yet.
- IRT `calibrate_period_report` thetas stay on the period-report
panel and are never copied into the TEPP envelope.

## Related

Outbox persistence is [ADR 0023](0023-tepp-valkey-outbox.md).
TEPP contract: ContextualWisdomLab/TEPP `docs/API_CONTRACT.md`.
30 changes: 30 additions & 0 deletions docs/adr/0023-tepp-valkey-outbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# ADR 0023 — Valkey outbox for TEPP envelopes

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

## Context

A fail-closed TEPP submit (ADR 0022) is lost if it only lives in the
HTTP response. Period-report IRT rows are the wrong table: they store
LineageWeave's own GRM/GPCM θ, not a TEPP run. An analysis-run
registry (Milestone 2.1 on the stacked branch) is a different
authority and must not be reimplemented here.

Valkey is already the product event queue (`activity:{post_id}`).

## Decision

1. Each TEPP submit `XADD`s onto `outbox:tepp` with outcome, next
action, and the published request identity (idempotency key,
snapshot, cutoff). No theta field.
2. `GET /api/tepp/outbox` returns the newest events for `post_read`.
3. `make seed` writes one crate-only fail-closed row so the home
panel names the next action after a fresh stack.

## Consequences

- The buyer sees the last TEPP attempt above the period-report
actions without opening a second product.
- A later analysis-run registry can drain this outbox; it must not
invent a score while doing so.
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.79.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
Loading
Loading