diff --git a/.env.example b/.env.example
index 7282c5a2e..439aecf17 100644
--- a/.env.example
+++ b/.env.example
@@ -39,6 +39,8 @@ MCP_RATE_LIMIT_WINDOW_SECONDS=
# running contextual-orchestrator to turn the channels on.
ORCHESTRATOR_BASE_URL=
ORCHESTRATOR_API_KEY=
+SOURCE_RESEARCH_MAXIMUM_LEADS=
+SOURCE_RESEARCH_MAXIMUM_RESULTS=
# GitHub workflows inject the canonical provider names from masked secrets.
# Non-GitHub Compose runs also accept the operator's ~/.env compatibility
@@ -49,7 +51,6 @@ LLM_GATEWAY_API_URL=
# Compatibility alias; LLM_GATEWAY_API_URL wins when both are set.
LLM_GATEWAY_URL=
LLM_GATEWAY_API_KEY=
-LLM_GATEWAY_EMBEDDING_MODEL=
LLM_API_GATEWAY=
LLM_API_KEY=
CALDAV_BASE_URL=
diff --git a/AGENTS.md b/AGENTS.md
index 3af4169bc..194b4dfd4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,6 +21,14 @@ documents unless an ADR explicitly promotes a decision from them --
to its governing ADR. Update research notes as literature changes; never
use them to introduce an untracked architecture decision.
+Customer-facing copy must help the reader take the next product action. Do
+not expose implementation boundaries, provider or package names, schema
+versions, internal status or reason codes, environment variables, transport
+setup, hashes, or developer remediation instructions as explanatory UI copy.
+Keep that evidence in governed audit and administrator surfaces and logs;
+translate a customer-visible state into the source, decision, retry, or
+administrator action the reader can actually take.
+
## Hard rule: no real data in repository artifacts
This repository ships **synthetic fixtures only** (`lineageweave/fixtures.py`)
@@ -188,8 +196,10 @@ contextual-orchestrator owns model discovery and selection.
`NullEmbeddingClient`, `NullAdjudicationClient`,
`NullKeymanExtractionClient`, `NullEntityRelationshipClient`,
-`NullPostSummaryClient`, `NullPostChatClient`, and
-`NullCommitmentExtractionClient` (and any new channel client you add)
+`NullPostSummaryClient`, `NullPostChatClient`,
+`NullCommitmentExtractionClient`, `NullRelationVerificationClient`,
+`NullClaimVerificationClient`, and `NullSourceResearchClient`
+(and any new channel client you add)
must set `available = False` and make their channel dropped +
renormalized (`reconstruct.active_weights`), never silently return a
placeholder score, invented Keyman, guessed relationship, fabricated
@@ -202,6 +212,14 @@ adjudication does -- never a raw LLM API. Demo TEPP seed goes through
envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`),
never a fabricated theta or a local psychometric substitute.
+Public source-reference research (ADR 0274) is a post-scoped write action
+on existing semantic units or image regions. Only `visibility_code=public`
+posts may send lead text to SearXNG or retrieve a result URL. Private posts
+fail closed without egress. Redirects and non-global targets are rejected.
+Unavailable search, retrieval, or adjudication is `research_unavailable`,
+never a fabricated supported/refuted judgment. Global Ask public
+verification (ADR 0215) still never fetches result URLs.
+
The lineage `text` channel follows [ADR 0190](docs/adr/0190-lineage-text-channel-embedding-swap.md):
when an embedding provider is configured, `reconstruct()` precomputes
batched label embeddings once per reconstruction and scores cosine
@@ -237,6 +255,20 @@ vector degrades that pair back to difflib; it never fabricates a score.
## Tests
+### Isolated Compose lifecycle
+
+- The canonical standalone Compose project is `lineageweave` (ADR 0224).
+- A test, review, or stacked-PR environment may use an explicit isolated
+ project name only while that environment is needed. Once its stated test or
+ review objective has succeeded, preserve the relevant evidence and port any
+ required behavior into the canonical Compose contract, then run `docker
+ compose -p down` so its containers and network do not become
+ a second production-looking stack.
+- Never use `down -v` or otherwise delete named volumes without separate,
+ explicit authorization. Resolve the exact project from Compose labels before
+ cleanup; never target a glob, directory root, or another agent's active
+ environment.
+
```bash
# backend extra compiles fast-mlsirm's PyO3 core -- needs rustc 1.97.1
# (see backend/Dockerfile). Without it, pip falls over at build time.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 05f98e69e..4cd9e2eff 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -67,7 +67,7 @@ flowchart LR
| `models.py` | `Record`, `Edge`, `Tree` -- source-agnostic data shapes |
| `channels.py` | Independent `[0, 1]` scoring functions |
| `chunking.py` | Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order |
-| `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` |
+| `embedding_client.py` | Provider-neutral contextual-orchestrator embedding transport and strict vector-envelope validation; no local similarity arithmetic |
| `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). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the buyer sees the picture, not the base64 string; GET does not call the vision client. |
| `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport |
@@ -172,6 +172,10 @@ real-provider LLM tests).
provider (`docker/keycloak/realm-export.json` seeds a `lineageweave-demo`
realm with synthetic demo accounts carrying `corp_code` / `pu_code` as
custom token claims -- see [README](README.md#local-product-stack-docker-compose)).
+ADR 0224 fixes the default project name to `lineageweave` and keeps the
+migration, SearXNG, contextual-orchestrator, backend, worker, and frontend in
+that same project. Test stacks use an explicit disposable `-p` name; they never
+replace a canonical service with a container built from another worktree.
`scripts/smoke_test_oidc.py` proves the round-trip is real: it logs in as
the synthetic demo user, fetches Keycloak's live JWKS, and cryptographically
verifies the returned JWT's RS256 signature rather than just checking for an
@@ -462,6 +466,14 @@ name, confirmed the events on the activity endpoint, and independently
confirmed the stream's existence and length with `valkey-cli` directly
against the `valkey` container.
+Post-content ingestion uses the same transport with a stronger durability
+boundary (ADR 0098): PostgreSQL owns each job and Valkey only wakes the worker.
+`POST /api/post-content/backfill` is a `post_admin`-gated producer for one
+1--200-row eligible page. It commits jobs before publishing, returns HTTP 202
+without running semantic providers, and reports wake-ups that the worker's
+bounded recovery sweep must republish. `FOR UPDATE SKIP LOCKED` partitions
+concurrent operator calls without a second scheduler or an in-memory task.
+
## Phase 5c: customer commitment derivation and the calendar
The brief asked for two separate-sounding things: issues auto-registered
@@ -805,6 +817,18 @@ HTML-wrapped, base64-image-embedded version of the existing
people through the live `/extract-keymen` endpoint
(`test_extract_keymen_normalizes_html_and_embedded_image_content`).
+## Evidence-operations lifecycle projection
+
+ADR 0206's Dashboard persists a semantic classification separately from its
+facts and observed milestones. `operations_case_milestone` binds a closed XES-
+style activity code to an exact evidence span, evidence-post digest, observed
+instant, and named source clock; `operations_case_missing_milestone` records an
+unsupported required endpoint without fabricating one. The Dashboard pairs
+only the three declared start/end definitions for claim investigation, rebid
+response, and handover. Both endpoints yield `end - start`; a cited start plus
+a missing end is open with nullable elapsed time. API projection rechecks
+current ABAC for focal and evidence posts before returning either span.
+
## Phase 6d: external search verification for Ontology relation inferences
The brief requires an external web/internal search agent to check the
@@ -854,6 +878,19 @@ against a deliberately fabricated one in the same request, asserting
the former comes back `verify_corroborated` with a real evidence URL
and the latter `verify_uncorroborated` with none.
+## Phase 6e: post-scoped source-reference research
+
+Issue #611's remaining ADR 0133 criterion is a different workflow from
+relation verification and from Global Ask snippet verification (ADR 0215).
+A public post may send an existing semantic unit or image-region excerpt
+to self-hosted SearXNG, retrieve one cited public page under SSRF and
+redirect rejection, and ask contextual-orchestrator to judge in
+`mode="verify"`. Private posts fail closed without egress. Citations
+persist to `source_research_citation` (migration 0236, ADR 0274). The
+reader next action is to open the cited public resource and compare it
+with the highlighted passage or image detail. Global Ask still never
+fetches result URLs.
+
## Phase 7: R&R's named actor is a PROV-O Agent, not always a person
`post_summary.py`'s R&R extraction forced every named actor into a
diff --git a/CHANGELOG.d/2.19.0-post-scoped-source-reference-research.md b/CHANGELOG.d/2.19.0-post-scoped-source-reference-research.md
new file mode 100644
index 000000000..4555c3fda
--- /dev/null
+++ b/CHANGELOG.d/2.19.0-post-scoped-source-reference-research.md
@@ -0,0 +1,17 @@
+# 2.19.0 — Post-scoped source-reference research
+
+## Added
+
+- Public posts can research a highlighted passage or image detail against a
+ cited public page (ADR 0274, remaining ADR 0133 / issue #611). The workflow
+ reuses self-hosted SearXNG, retrieves one public HTTP(S) target with
+ redirects disabled and non-global addresses rejected, and judges through
+ contextual-orchestrator `mode=verify`. Private posts fail closed without
+ egress. Deployments must set both source-research resource budgets explicitly;
+ otherwise the channel remains unavailable. Citations persist in 3NF
+ `source_research_citation`.
+- Reader next action: open the cited public resource, then compare it with
+ the highlighted passage or image detail. Supported or refuted judgments
+ without a cited URL downgrade to not enough information. Missing search,
+ retrieval, or adjudication is `research_unavailable`, never a fabricated
+ score.
diff --git a/CHANGELOG.d/2.20.0-backend-contract-regressions.md b/CHANGELOG.d/2.20.0-backend-contract-regressions.md
index a391d991b..02c90e63b 100644
--- a/CHANGELOG.d/2.20.0-backend-contract-regressions.md
+++ b/CHANGELOG.d/2.20.0-backend-contract-regressions.md
@@ -1,3 +1,3 @@
### Fixed
-- Restored the runtime-only TEPP credential setting, kept API integration fixtures collision-free, aligned asynchronous Ask tests with semantic retrieval, replaced deprecated FastAPI 422 constants, and moved Starlette integration tests to its supported `httpx2` transport.
+- Restored the runtime-only TEPP credential setting, kept API integration fixtures collision-free, aligned asynchronous Ask tests with semantic retrieval, replaced deprecated FastAPI 422 constants, moved Starlette integration tests to its supported `httpx2` transport, and adopted the SPDX license expression required by current packaging metadata.
diff --git a/CHANGELOG.d/2.21.2-orchestrator-promotion-preflight.md b/CHANGELOG.d/2.21.2-orchestrator-promotion-preflight.md
new file mode 100644
index 000000000..6417985b4
--- /dev/null
+++ b/CHANGELOG.d/2.21.2-orchestrator-promotion-preflight.md
@@ -0,0 +1,5 @@
+### Fixed
+
+- Fail closed before recreating the canonical contextual-orchestrator when the
+ exact candidate cannot authenticate a structured-capable model through the
+ currently configured gateway endpoint.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a2724eb8..babcdf489 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,71 @@ All notable changes to this project are documented here. Format follows
## [Unreleased]
-### Added
+- Evidence-rich API responses now use standard HTTP gzip content negotiation,
+ and the Dashboard k6 gate requires and verifies that negotiated transfer.
+ The exact payload measured 96,415 bytes uncompressed and 10,282 bytes on the
+ wire; its 130.43 ms five-VU maximum keeps the 20 ms release gate open
+ (ADR 0272).
+
+- Global Ask now has a consumer-selective durable worker in the canonical
+ Compose project. Per-consumer PostgreSQL advisory leases prevent overlapping
+ queue owners, while stopping post-content consumption no longer suspends Ask
+ or starts any post-content recovery from the Ask worker (ADR 0279).
+
+- Post-content recovery now keys every initial attempt, retry, and stale lease
+ by its exact eligibility instant, so work that becomes due after the durable
+ cursor advances is reached without waiting for a full ledger wrap.
+
+- Global Ask public verification now admits only bounded, provenance-bearing
+ persisted claims attached to exact cited public posts; missing admission
+ fails closed without token-overlap egress.
+
+### Added
+
+- Product extraction now uses a strict receipt-bearing orchestrator schema,
+ revalidates the focal source and complete typed-target digest under a shared
+ replacement lock, and continues after an independent operations-stage
+ failure. Typed operations/project targets are admitted only from current
+ evidence-bearing assertions; catalog misses remain misses without guessed
+ identities, and a later admission delay cannot hide an ordinary stage
+ failure (ADR 0228).
+
+- Derived Voice classification now uses one strict contextual-orchestrator
+ receipt over the exact focal Post body, admits all twelve governed Voice
+ concepts as evidence-supported multi-label assertions, and preserves the
+ imported source category separately. Exact spans and source digests fail
+ closed; valid empty results retain a completion receipt, and operations-case
+ failures no longer suppress the independent Voice stage (ADR 0244).
+
+- Governed product-catalog provisioning now accepts only explicit product
+ master rows with authorized source-record provenance, a canonical payload
+ digest, and source-linked aliases. Replays are idempotent, contradictory
+ definitions fail closed, and unresolved Post evidence tells the reader the
+ next catalog action without creating identities from model output, keywords,
+ fuzzy matches, or generic source categories.
+
+- Temporal topic influence now has a durable external-production path: the
+ worker binds the exact completed TEPP artifact, posterior draws, and
+ business-unit/PU/team/person memberships into a content-addressed request,
+ then persists only a complete, converged, identified, parity-passed
+ fast-mlsirm result. Missing owner transport, partial rows, or digest mismatch
+ remains unavailable without local scoring. Time-valid membership slices
+ remain distinct; incomplete evidence enters an event-woken awaiting state;
+ expired work is reclaimed only from its declared request/lease contract,
+ whose lease must strictly exceed the request timeout for persistence; and
+ every terminal transition matches a unique lease token. Evidence changed
+ during computation releases a fresh request automatically.
+ LineageWeave-owned request and membership bytes and producer-owned result
+ bytes are SHA-256 verified before parsing, so admission never depends on
+ cross-language JSON reserialization. Other
+ retries use only an exact remote delay or an explicit operator requeue
+ (ADR 0210).
+
+- Evidence Operations now presents cited claim, rebid, handover, external,
+ product, and Voice evidence with explicit unavailable states and source-open
+ actions. Durable analysis and bounded backfill run only in the dedicated
+ backend worker, while the web process remains responsive; topic-context
+ results fail closed when their authorized provenance is incomplete.
- Period leftover pairs now caption leftover-map graphic-display pair
segments with persisted leftover-map distance `d` (ADR 0271 /
@@ -268,6 +332,21 @@ All notable changes to this project are documented here. Format follows
### Fixed
+- Product requirement identifiers are now unique and authoritative; duplicated
+ occupational-taxonomy sections were consolidated without changing their
+ evidence, measurement, or provider boundaries.
+- Public-claim provenance validation now selects its sole UUID binding without
+ calling PostgreSQL's unavailable `min(uuid)` aggregate, so fresh schema
+ replays accept valid provenance-bound public claims.
+- Post-content wake-up recovery now advances through every ready ledger page
+ with a deterministic keyset, while Valkey trims only entries already
+ consumed by the worker. Large backfills can no longer replay the same first
+ page until a later queued record starves or its unread wake-up is trimmed.
+- Post-content ingestion now keeps its bounded Valkey reader and durable-ledger
+ recovery sweep live while the single provider pipeline awaits a slow
+ structured operation. Provider work remains serial; PostgreSQL claim and
+ expected-attempt fencing, restart recovery, and the existing retry budget are
+ unchanged (ADR 0098).
- Full-corpus Event Lineage rebuilds now count candidate pairs before provider
work and omit the optional LLM channel above the 5,000-pair ADR budget,
preventing millions of synchronous orchestrator calls while retaining one
diff --git a/Makefile b/Makefile
index b6764b72f..56e13515a 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@
# Keep provider credentials outside the repository. Compose interpolation must
# read the same home env file as the orchestrator container's env_file.
-COMPOSE := docker compose --env-file "$$HOME/.env"
+COMPOSE := COMPOSE_FILE=docker-compose.yml docker compose --env-file "$$HOME/.env"
up:
$(COMPOSE) up -d
@@ -21,7 +21,7 @@ ps:
# Keycloak's live JWKS, and asserts the corp_code/pu_code claims. See
# scripts/smoke_test_oidc.py.
smoke:
- uv run --locked python scripts/smoke_test_oidc.py
+ uv run --locked --extra dev python scripts/smoke_test_oidc.py
# Seeds synthetic corp/account/post rows keyed to the actual Keycloak demo
# users' real subject ids, plus Valkey ticket_created events so Activity
diff --git a/README.md b/README.md
index 5f1480b40..85025c9fa 100644
--- a/README.md
+++ b/README.md
@@ -123,6 +123,16 @@ compatibility aliases only. `ORCHESTRATOR_BASE_URL` and
`ORCHESTRATOR_API_KEY` are separate, internal
LineageWeave-to-orchestrator settings.
+The Compose file declares `lineageweave` as its canonical default project, so
+the same eight-service synthetic stack is addressed from the repository and
+temporary worktrees. Isolated tests may override it explicitly with `-p`; use
+`docker compose down` without `-v` when retiring such a project so its named
+volumes remain recoverable (ADR 0224).
+The bundled Keycloak realm is the standalone/local/dev/test fallback only.
+Setting `KEYVERSE_ISSUER` selects central Keyverse for both backend and
+frontend and activates the fail-closed claim binding in ADR 0156; the two
+issuers are never combined as authorization authorities.
+
Postgres and Keycloak are built (`docker/postgres-init/`, `docker/keycloak/`)
rather than bind-mounted, so the keycloak database's init script and the
realm seed ship inside the images themselves -- portable to any Docker host
@@ -148,6 +158,12 @@ no re-typed copy.
`backend/` is a FastAPI app talking directly to that database (`asyncpg`,
no ORM, no file DB) and to Keycloak's live JWKS for OIDC verification:
+The API does not consume durable jobs. Canonical Compose makes `backend`
+depend on the progress-healthy `backend-worker`, so `docker compose up backend`
+starts both. Any non-Compose deployment must co-deploy
+`python -m backend.app.worker` and gate API readiness on that worker service;
+`/healthz` is process liveness only.
+
```bash
make up
make seed # scripts/seed_demo_data.py: inserts synthetic corp/account/post
@@ -168,6 +184,11 @@ docker compose --profile mcp up mcp
# Streamable HTTP resource: http://localhost:18001/mcp
```
+For client initialization, tool arguments, durable status handling, and quota
+recovery, see the [MCP manual](docs/manuals/mcp-manual.md). Workspace users can
+start with the [user guide](docs/manuals/user-guide.md); deployment and incident
+procedures are in the [operations manual](docs/manuals/operations-manual.md).
+
`GET /api/posts`, `GET /api/posts/{post_id}`,
`GET /api/posts/{post_id}/keymen`, `GET /api/keymen/{person_id}/related`,
`GET /api/posts/{post_id}/affiliate-tree`,
diff --git a/backend/Dockerfile b/backend/Dockerfile
index eb6b86288..e46b44dcc 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -20,20 +20,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
-ENV PATH="/app/.venv/bin:/root/.cargo/bin:${PATH}"
+ENV PATH="/app/.venv/bin:/root/.cargo/bin:${PATH}" \
+ UV_LINK_MODE=copy
COPY pyproject.toml uv.lock README.md ./
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv sync --frozen --no-dev --extra backend --no-install-project --no-editable
COPY lineageweave ./lineageweave
COPY backend ./backend
+COPY scripts/backfill_post_embeddings.py ./scripts/backfill_post_embeddings.py
# lineageweave/ontology.py resolves this path relative to itself
# (parents[1] = /app) -- ADR 0004.
COPY docs/ontology ./docs/ontology
# Install exactly the committed universal lock. --no-editable prevents a
# runtime dependency on source-tree editability while retaining package data.
-RUN uv sync --frozen --no-dev --extra backend --no-editable \
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv sync --frozen --no-dev --extra backend --no-editable \
&& chown -R appuser:appuser /app
+ARG LINEAGEWEAVE_SOURCE_REVISION=unknown
+LABEL org.opencontainers.image.revision=${LINEAGEWEAVE_SOURCE_REVISION}
USER appuser
EXPOSE 8000
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py
index aa04aa23d..86dbdc6a1 100644
--- a/backend/app/analysis_run_ingestion.py
+++ b/backend/app/analysis_run_ingestion.py
@@ -655,14 +655,12 @@ def _require_lineage_create_kind(run_kind_code: str) -> None:
if run_kind_code == _TEPP_RUN_KIND:
raise AnalysisRunCreateError(
422,
- "Connect a TEPP transport from a Failed TEPP row; this endpoint "
- "does not invent a measurement.",
+ "Open the failed temporal measurement, ask an administrator to restore analysis, then re-run it.",
)
if run_kind_code == _TOPIC_LINEAGE_RUN_KIND:
raise AnalysisRunCreateError(
422,
- "Connect a TEPP transport from a Failed topic-lineage row; this "
- "endpoint does not invent a topic model.",
+ "Open the failed topic journey analysis, ask an administrator to restore analysis, then re-run it.",
)
if run_kind_code == _REPORT_RUN_KIND:
raise AnalysisRunCreateError(
diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py
index cf7c249c2..8197093b3 100644
--- a/backend/app/analysis_run_start.py
+++ b/backend/app/analysis_run_start.py
@@ -158,13 +158,11 @@ def start_kind_rejection(run_kind_code: str) -> AnalysisRunStartError | None:
if run_kind_code == _REPORT_KIND:
return AnalysisRunStartError(
422,
- "Rebuild the period report from the reports panel. "
- "This start path does not invent a measurement.",
+ "기간 보고서 화면에서 다시 계산하세요.",
)
return AnalysisRunStartError(
422,
- "Start reconstructs a Pending lineage run or submits TEPP. "
- "This start path does not invent a measurement.",
+ "지원되는 분석 유형을 선택한 뒤 다시 시작하세요.",
)
diff --git a/backend/app/auth.py b/backend/app/auth.py
index 41085d7e3..c887b45e0 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -13,6 +13,7 @@
from __future__ import annotations
+import asyncio
import json
from dataclasses import dataclass
@@ -30,6 +31,11 @@
_jwks_cache: dict[tuple[str, str, str], dict] = {}
+async def warm_oidc_jwks(settings: Settings) -> None:
+ """Populate the issuer-bound JWKS cache before serving authenticated reads."""
+ await asyncio.to_thread(_jwks, settings)
+
+
def _jwks_cache_key(settings: Settings) -> tuple[str, str, str]:
"""Bind cached keys to the exact issuer and key-discovery configuration."""
return (
@@ -201,8 +207,20 @@ async def resolve_current_account(
account_row = await conn.fetchrow(
"""
select account.user_account_id, account.display_name,
- account.preferred_locale, affiliation.corporate_entity_id,
- affiliation.process_unit_id
+ account.preferred_locale,
+ array[affiliation.corporate_entity_id] as corporate_entity_ids,
+ array[affiliation.process_unit_id] as process_unit_ids,
+ coalesce((
+ select array_agg(distinct permission.permission_code
+ order by permission.permission_code)
+ from account_role_assignment assignment
+ join access_role role
+ on role.access_role_id = assignment.access_role_id
+ and role.role_code = any($4::text[])
+ join role_permission permission
+ on permission.access_role_id = role.access_role_id
+ where assignment.user_account_id = account.user_account_id
+ ), array[]::text[]) as permission_codes
from user_account account
join account_affiliation affiliation
on affiliation.user_account_id = account.user_account_id
@@ -218,10 +236,31 @@ async def resolve_current_account(
subject,
organization,
workspace,
+ token_roles,
)
else:
account_row = await conn.fetchrow(
- "select user_account_id, display_name, preferred_locale from user_account where external_subject_id = $1",
+ """
+ select account.user_account_id, account.display_name,
+ account.preferred_locale,
+ coalesce((
+ select array_agg(distinct affiliation.corporate_entity_id
+ order by affiliation.corporate_entity_id)
+ from account_affiliation affiliation
+ where affiliation.user_account_id = account.user_account_id
+ ), array[]::uuid[]) as corporate_entity_ids,
+ array[]::uuid[] as process_unit_ids,
+ coalesce((
+ select array_agg(distinct permission.permission_code
+ order by permission.permission_code)
+ from account_role_assignment assignment
+ join role_permission permission
+ on permission.access_role_id = assignment.access_role_id
+ where assignment.user_account_id = account.user_account_id
+ ), array[]::text[]) as permission_codes
+ from user_account account
+ where account.external_subject_id = $1
+ """,
subject,
)
if account_row is None:
@@ -231,47 +270,14 @@ async def resolve_current_account(
"(run scripts/seed_demo_data.py, or provision the account, first)",
)
- if keyverse_scope:
- entity_rows = [{"corporate_entity_id": account_row["corporate_entity_id"]}]
- process_rows = [{"process_unit_id": account_row["process_unit_id"]}]
- permission_rows = await conn.fetch(
- """
- select distinct permission.permission_code
- from account_role_assignment assignment
- join access_role role
- on role.access_role_id = assignment.access_role_id
- join role_permission permission
- on permission.access_role_id = assignment.access_role_id
- where assignment.user_account_id = $1
- and role.role_code = any($2::text[])
- """,
- account_row["user_account_id"],
- token_roles,
- )
- else:
- entity_rows = await conn.fetch(
- "select corporate_entity_id from account_affiliation where user_account_id = $1",
- account_row["user_account_id"],
- )
- process_rows = []
- permission_rows = await conn.fetch(
- """
- select distinct rp.permission_code
- from account_role_assignment ara
- join role_permission rp on rp.access_role_id = ara.access_role_id
- where ara.user_account_id = $1
- """,
- account_row["user_account_id"],
- )
-
return CurrentAccount(
user_account_id=str(account_row["user_account_id"]),
external_subject_id=subject,
display_name=account_row["display_name"],
preferred_locale=account_row["preferred_locale"],
- corporate_entity_ids=frozenset(str(row["corporate_entity_id"]) for row in entity_rows),
- process_unit_ids=frozenset(str(row["process_unit_id"]) for row in process_rows),
- permission_codes=frozenset(row["permission_code"] for row in permission_rows),
+ corporate_entity_ids=frozenset(str(value) for value in account_row["corporate_entity_ids"]),
+ process_unit_ids=frozenset(str(value) for value in account_row["process_unit_ids"]),
+ permission_codes=frozenset(account_row["permission_codes"]),
)
diff --git a/backend/app/config.py b/backend/app/config.py
index 0fea9a591..60f0eab2f 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -58,8 +58,15 @@ class Settings:
orchestrator_answer_timeout_seconds: float
valkey_url: str
searxng_base_url: str
+ source_research_maximum_leads: int | None
+ source_research_maximum_results: int | None
tepp_transport_url: str
tepp_api_key: str
+ topic_influence_transport_url: str
+ topic_influence_api_key: str
+ topic_influence_request_timeout_seconds: int | None
+ topic_influence_lease_timeout_seconds: int | None
+ topic_influence_poll_seconds: int | None
caldav_base_url: str
naruon_calendar_base_url: str
naruon_calendar_service_token: str
@@ -75,6 +82,7 @@ class Settings:
mcp_max_request_bytes: int = 65_536
mcp_rate_limit_requests: int | None = None
mcp_rate_limit_window_seconds: int | None = None
+ worker_consumers: str = ""
@property
def keycloak_jwks_uri(self) -> str:
@@ -205,8 +213,29 @@ def load_settings() -> Settings:
),
valkey_url=os.environ.get("VALKEY_URL", "redis://localhost:16379/0"),
searxng_base_url=os.environ.get("SEARXNG_BASE_URL", ""),
+ source_research_maximum_leads=_optional_positive_int(
+ "SOURCE_RESEARCH_MAXIMUM_LEADS"
+ ),
+ source_research_maximum_results=_optional_positive_int(
+ "SOURCE_RESEARCH_MAXIMUM_RESULTS"
+ ),
tepp_transport_url=os.environ.get("TEPP_TRANSPORT_URL", ""),
tepp_api_key=os.environ.get("TEPP_API_KEY", "").strip(),
+ topic_influence_transport_url=os.environ.get(
+ "TOPIC_INFLUENCE_TRANSPORT_URL", ""
+ ).strip(),
+ topic_influence_api_key=os.environ.get(
+ "TOPIC_INFLUENCE_API_KEY", ""
+ ).strip(),
+ topic_influence_request_timeout_seconds=_optional_positive_int(
+ "TOPIC_INFLUENCE_REQUEST_TIMEOUT_SECONDS"
+ ),
+ topic_influence_lease_timeout_seconds=_optional_positive_int(
+ "TOPIC_INFLUENCE_LEASE_TIMEOUT_SECONDS"
+ ),
+ topic_influence_poll_seconds=_optional_positive_int(
+ "TOPIC_INFLUENCE_POLL_SECONDS"
+ ),
caldav_base_url=os.environ.get("CALDAV_BASE_URL", "").strip(),
naruon_calendar_base_url=os.environ.get("NARUON_CALENDAR_BASE_URL", "").strip(),
naruon_calendar_service_token=os.environ.get(
@@ -241,4 +270,5 @@ def load_settings() -> Settings:
mcp_rate_limit_window_seconds=_optional_positive_int(
"MCP_RATE_LIMIT_WINDOW_SECONDS"
),
+ worker_consumers=os.environ.get("LINEAGEWEAVE_WORKER_CONSUMERS", ""),
)
diff --git a/backend/app/customer_hint_ingestion.py b/backend/app/customer_hint_ingestion.py
index 497c66728..c0480cfe3 100644
--- a/backend/app/customer_hint_ingestion.py
+++ b/backend/app/customer_hint_ingestion.py
@@ -20,6 +20,7 @@
from lineageweave.organization_name_resolution import resolve_and_verify_organization_name
from lineageweave.post_content_normalization import normalize_post_body
from lineageweave.relation_verification import STATUS_CORROBORATED, RelationVerificationClient
+from lineageweave.semantic_hints import customer_hint_trust
_EXCERPT_LENGTH = 1500
@@ -40,6 +41,8 @@ async def resolve_customer_hint(
its account's default placeholder entity, returning the entity
id/name plus how many posts were reclaimed.
"""
+ if customer_hint_trust(hint_code) == "low":
+ return None
if not resolution_client.available:
return None
# Five rows and 20,000 raw body characters per row bound both transfer and
diff --git a/backend/app/db.py b/backend/app/db.py
index 3dc481f3a..ed0343e40 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -6,10 +6,124 @@
import asyncpg
from fastapi import Request
+from backend.app.operations_dashboard import warm_operations_dashboard_read_statements
+from backend.app.voice_taxonomy import warm_voice_taxonomy_read_statements
+
+
+async def _initialize_connection(connection: asyncpg.Connection) -> None:
+ """Load array codecs before the first latency-bounded request."""
+ await connection.fetchrow(
+ "select array[]::uuid[] as uuid_values, array[]::text[] as text_values"
+ )
+ await warm_operations_dashboard_read_statements(connection)
+ await warm_voice_taxonomy_read_statements(connection)
+ await warm_customer_master_read_paths(connection)
+ await warm_post_list_read_paths(connection)
+
+
+async def warm_customer_master_read_paths(connection: asyncpg.Connection) -> None:
+ """Warm maintained Customer Master projections before readiness."""
+ await connection.fetch(
+ """
+ select customer_code_key, customer_name_group_key, sum(post_count)::bigint
+ from customer_hint_group_read_projection
+ where visibility_code = 'public'
+ group by customer_code_key, customer_name_group_key
+ order by sum(post_count) desc, customer_code_key, customer_name_group_key
+ limit 21
+ """
+ )
+ await connection.fetch(
+ """
+ select author_code, author_account_id, account_display_name,
+ sum(post_count)::bigint
+ from author_hint_group_read_projection
+ where visibility_code = 'public'
+ group by author_code, author_account_id, account_display_name
+ order by sum(post_count) desc, author_code, author_account_id,
+ account_display_name
+ limit 21
+ """
+ )
+ await connection.fetch(
+ """
+ select counterparty.counterparty_entity_name,
+ counterparty.relationship_type_code, count(*)::bigint
+ from post_counterparty_entity counterparty
+ join dashboard_post_read_projection post
+ on post.source_post_id = counterparty.post_id
+ where post.visibility_code = 'public' and post.source_context_present
+ group by counterparty.counterparty_entity_name,
+ counterparty.relationship_type_code
+ order by count(*) desc, counterparty.counterparty_entity_name
+ limit 100
+ """
+ )
+ await connection.fetch(
+ """
+ select post_id
+ from customer_master_post_read_projection
+ where customer_code_key = '' and customer_name_group_key = ''
+ order by created_at desc, post_id desc
+ limit 21
+ """
+ )
+
+
+async def warm_post_list_read_paths(connection: asyncpg.Connection) -> None:
+ """Read the measured Post page indexes before readiness is advertised."""
+ active = (
+ "(source_draft_code is null or btrim(source_draft_code) = '') and "
+ "(source_deleted_flag is null or btrim(source_deleted_flag) = '')"
+ )
+ source_context = (
+ "(nullif(btrim(source_author_code), '') is not null or "
+ "nullif(btrim(source_author_name), '') is not null or "
+ "nullif(btrim(source_company_code), '') is not null or "
+ "nullif(btrim(source_company_name), '') is not null or "
+ "nullif(btrim(source_process_unit_code), '') is not null or "
+ "nullif(btrim(source_process_unit_name), '') is not null or "
+ "nullif(btrim(source_sales_pool_code), '') is not null or "
+ "nullif(btrim(source_sales_pool_name), '') is not null or "
+ "nullif(btrim(source_customer_code), '') is not null or "
+ "nullif(btrim(source_customer_name), '') is not null or "
+ "nullif(btrim(source_project_code), '') is not null or "
+ "nullif(btrim(source_project_name), '') is not null)"
+ )
+ # Safe SQL: both predicates above are closed schema constants.
+ for ordering in (
+ "created_at desc, post_id desc",
+ "lower(coalesce(post_title, '')), created_at desc, post_id desc",
+ ):
+ await connection.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ "select post_id from source_post where "
+ f"{active} and {source_context} order by {ordering} limit 50"
+ )
+ await connection.fetch(
+ "select projection.post_id, projection.post_body_excerpt, "
+ "voice.voice_type_code from post_list_read_projection projection "
+ "left join source_post_voice voice on voice.post_id = projection.post_id "
+ "and voice.effective_to is null order by projection.post_id limit 50"
+ )
+
+
+async def _reset_connection(connection: asyncpg.Connection) -> None:
+ """Reset request state and restore the measured generic-plan policy."""
+ await connection.reset()
+ await connection.execute("set plan_cache_mode = 'force_generic_plan'")
+
async def create_pool(database_url: str) -> asyncpg.Pool:
"""Open the process-wide asyncpg pool against ``database_url``."""
- return await asyncpg.create_pool(database_url, min_size=1, max_size=10)
+ return await asyncpg.create_pool(
+ database_url,
+ min_size=10,
+ max_size=10,
+ max_cacheable_statement_size=0,
+ server_settings={"jit": "off", "plan_cache_mode": "force_generic_plan"},
+ init=_initialize_connection,
+ reset=_reset_connection,
+ )
def get_pool(request: Request) -> asyncpg.Pool:
diff --git a/backend/app/demo_scope.py b/backend/app/demo_scope.py
index 6d25bee24..2f482732f 100644
--- a/backend/app/demo_scope.py
+++ b/backend/app/demo_scope.py
@@ -28,23 +28,10 @@ async def has_real_source_context(
"""
select exists (
select 1
- from source_post
+ from dashboard_post_read_projection source_post
where (visibility_code = 'public'
or corporate_entity_id = any($1::uuid[]))
- and (
- nullif(btrim(source_post.source_author_code), '') is not null
- or nullif(btrim(source_post.source_author_name), '') is not null
- or nullif(btrim(source_post.source_company_code), '') is not null
- or nullif(btrim(source_post.source_company_name), '') is not null
- or nullif(btrim(source_post.source_process_unit_code), '') is not null
- or nullif(btrim(source_post.source_process_unit_name), '') is not null
- or nullif(btrim(source_post.source_sales_pool_code), '') is not null
- or nullif(btrim(source_post.source_sales_pool_name), '') is not null
- or nullif(btrim(source_post.source_customer_code), '') is not null
- or nullif(btrim(source_post.source_customer_name), '') is not null
- or nullif(btrim(source_post.source_project_code), '') is not null
- or nullif(btrim(source_post.source_project_name), '') is not null
- )
+ and source_context_present
)
""",
list(corporate_entity_ids),
@@ -61,22 +48,9 @@ async def fetch_demo_corporate_entity_ids(conn: asyncpg.Connection) -> set[str]:
where entity.corporate_entity_code like 'DEMO-%'
and not exists (
select 1
- from source_post real_post
+ from dashboard_post_read_projection real_post
where real_post.corporate_entity_id = entity.corporate_entity_id
- and (
- nullif(btrim(real_post.source_author_code), '') is not null
- or nullif(btrim(real_post.source_author_name), '') is not null
- or nullif(btrim(real_post.source_company_code), '') is not null
- or nullif(btrim(real_post.source_company_name), '') is not null
- or nullif(btrim(real_post.source_process_unit_code), '') is not null
- or nullif(btrim(real_post.source_process_unit_name), '') is not null
- or nullif(btrim(real_post.source_sales_pool_code), '') is not null
- or nullif(btrim(real_post.source_sales_pool_name), '') is not null
- or nullif(btrim(real_post.source_customer_code), '') is not null
- or nullif(btrim(real_post.source_customer_name), '') is not null
- or nullif(btrim(real_post.source_project_code), '') is not null
- or nullif(btrim(real_post.source_project_name), '') is not null
- )
+ and real_post.source_context_present
)
"""
)
diff --git a/backend/app/entity_relationship_ingestion.py b/backend/app/entity_relationship_ingestion.py
index 3eb4fa327..747798ca8 100644
--- a/backend/app/entity_relationship_ingestion.py
+++ b/backend/app/entity_relationship_ingestion.py
@@ -161,47 +161,13 @@ async def fetch_relationship_network(
counterparty.relationship_type_code,
lookup.lookup_label as relationship_label
from post_counterparty_entity counterparty
- join source_post post on post.post_id = counterparty.post_id
+ join dashboard_post_read_projection post
+ on post.source_post_id = counterparty.post_id
join common_lookup_value lookup
on lookup.lookup_code = counterparty.relationship_type_code
where (post.visibility_code = 'public'
or post.corporate_entity_id = any($1::uuid[]))
- and nullif(btrim(post.source_draft_code), '') is null
- and nullif(btrim(post.source_deleted_flag), '') is null
- and not (
- (
- nullif(btrim(post.source_author_code), '') is null
- and nullif(btrim(post.source_author_name), '') is null
- and nullif(btrim(post.source_company_code), '') is null
- and nullif(btrim(post.source_company_name), '') is null
- and nullif(btrim(post.source_process_unit_code), '') is null
- and nullif(btrim(post.source_process_unit_name), '') is null
- and nullif(btrim(post.source_sales_pool_code), '') is null
- and nullif(btrim(post.source_sales_pool_name), '') is null
- and nullif(btrim(post.source_customer_code), '') is null
- and nullif(btrim(post.source_customer_name), '') is null
- and nullif(btrim(post.source_project_code), '') is null
- and nullif(btrim(post.source_project_name), '') is null
- )
- and exists (
- select 1
- from source_post real_post
- where (
- nullif(btrim(real_post.source_author_code), '') is not null
- or nullif(btrim(real_post.source_author_name), '') is not null
- or nullif(btrim(real_post.source_company_code), '') is not null
- or nullif(btrim(real_post.source_company_name), '') is not null
- or nullif(btrim(real_post.source_process_unit_code), '') is not null
- or nullif(btrim(real_post.source_process_unit_name), '') is not null
- or nullif(btrim(real_post.source_sales_pool_code), '') is not null
- or nullif(btrim(real_post.source_sales_pool_name), '') is not null
- or nullif(btrim(real_post.source_customer_code), '') is not null
- or nullif(btrim(real_post.source_customer_name), '') is not null
- or nullif(btrim(real_post.source_project_code), '') is not null
- or nullif(btrim(real_post.source_project_name), '') is not null
- )
- )
- )
+ and post.source_context_present
), grouped as (
select counterparty_entity_name,
relationship_type_code,
diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py
index 9bffd8502..5a02b8721 100644
--- a/backend/app/global_ask_queue.py
+++ b/backend/app/global_ask_queue.py
@@ -38,7 +38,6 @@
ClaimVerificationClient,
ClaimVerificationResult,
NullClaimVerificationClient,
- public_claim_candidates,
)
from lineageweave.embedding_client import EmbeddingClient, NullEmbeddingClient
from lineageweave.http_client import HttpClientError
@@ -48,9 +47,14 @@
PostChatClient,
ask_grounding_status,
cited_post_evidence,
+ cited_post_events,
cited_post_summaries,
historical_body_limitations,
)
+from lineageweave.public_claim_envelope import (
+ PersistedPublicClaimEnvelope,
+ envelope_from_authorized_row,
+)
from lineageweave.semantic_query import NullSemanticQueryClient, SemanticQueryClient
from lineageweave.temporal_expressions import resolve_korean_relative_time
@@ -63,6 +67,7 @@
gather_global_chat_sources,
prepare_global_question_embedding,
)
+from .source_research_ingestion import list_ask_source_references
GLOBAL_ASK_STREAM_KEY = "global_ask_request_stream"
@@ -76,6 +81,10 @@
# trimmed stream) and are republished by the worker's recovery sweep.
_REPUBLISH_AFTER_SECONDS = 60
_RECOVERY_INTERVAL_SECONDS = 30.0
+_ASK_RETRY_MESSAGE = (
+ "Ask Agent is unavailable. Retry in a moment. If this continues, "
+ "contact your workspace administrator."
+)
# Hard ceiling on one job's answer computation. Without it a hung
# orchestrator round-trip kept a job `running` indefinitely (observed:
# 17+ minutes) and, before concurrent processing, stalled every job
@@ -97,6 +106,33 @@
_logger = logging.getLogger(__name__)
+_AUTHORIZED_PUBLIC_CLAIM_ENVELOPES_SQL = """
+ select envelope.public_claim_envelope_id,
+ envelope.source_post_id,
+ envelope.claim_kind_code,
+ envelope.claim_text
+ from public_claim_envelope envelope
+ join source_post post on post.post_id = envelope.source_post_id
+ join provenance_assertion assertion
+ on assertion.assertion_id = envelope.provenance_assertion_id
+ and assertion.relation_code = 'prov_was_derived_from'
+ where envelope.egress_eligible
+ and post.visibility_code = 'public'
+ and envelope.source_post_id = any($1::uuid[])
+ and exists (
+ select 1
+ from provenance_resource_binding evidence
+ where evidence.resource_id = assertion.object_resource_id
+ and evidence.node_type_code = 'node_post'
+ and evidence.node_id = envelope.source_post_id
+ )
+ and ($2::timestamptz is null or (
+ envelope.created_at <= $2 and post.created_at <= $2
+ ))
+ order by envelope.created_at, envelope.public_claim_envelope_id
+ limit 4
+"""
+
class _SafeJobError(Exception):
"""Failure whose bounded message is safe to persist for the requester."""
@@ -168,11 +204,11 @@ def _verification_next_action(status_code: str) -> str:
return {
VERIFICATION_SKIPPED: "Enable public verification to check eligible public claims.",
- VERIFICATION_UNAVAILABLE: "Configure public search and contextual-orchestrator, then retry.",
- VERIFICATION_NO_PUBLIC_CLAIMS: "Inspect the internal cited posts; no public claim was eligible.",
+ VERIFICATION_UNAVAILABLE: "Ask a workspace administrator to enable public verification, then retry.",
+ VERIFICATION_NO_PUBLIC_CLAIMS: "Ask about a specific claim or narrow the time range, then retry.",
VERIFICATION_COMPLETED: "Inspect public evidence separately before any governed graph review.",
CLAIM_NOT_ENOUGH_INFORMATION: "Collect stronger authoritative evidence before accepting the claim.",
- }.get(status_code, "Inspect the authorized cited posts and their evidence.")
+ }.get(status_code, "Ask about a specific claim or narrow the time range, then retry.")
async def _verify_public_claims(
@@ -182,16 +218,20 @@ async def _verify_public_claims(
*,
verify_external: bool,
client: ClaimVerificationClient,
+ persisted_envelopes: tuple[PersistedPublicClaimEnvelope, ...] = (),
) -> tuple[str, tuple[ClaimVerificationResult, ...]]:
- """Verify only cited claims explicitly marked safe for public egress."""
+ """Verify only cited claims explicitly marked safe for public egress.
+
+ Only persisted admission envelopes may cross the public verifier. Omitting
+ them fails closed; question-token overlap is not an admission mechanism.
+ """
if not verify_external:
return VERIFICATION_SKIPPED, ()
cited_ids = frozenset(cited_post_ids)
+ claims = tuple(envelope.verification_candidate() for envelope in persisted_envelopes)
claims = tuple(
- claim
- for claim in public_claim_candidates(sources, question)
- if set(claim.source_post_ids).issubset(cited_ids)
+ claim for claim in claims if set(claim.source_post_ids).issubset(cited_ids)
)
if not claims:
return VERIFICATION_NO_PUBLIC_CLAIMS, ()
@@ -213,6 +253,28 @@ async def _verify_public_claims(
)
+async def load_authorized_public_claim_envelopes(
+ conn: asyncpg.Connection,
+ cited_post_ids: list[str],
+ *,
+ knowledge_cutoff: datetime | None,
+) -> tuple[PersistedPublicClaimEnvelope, ...]:
+ """Load bounded persisted claims for exact cited public evidence posts."""
+
+ if not cited_post_ids:
+ return ()
+ rows = await conn.fetch(
+ _AUTHORIZED_PUBLIC_CLAIM_ENVELOPES_SQL,
+ cited_post_ids,
+ knowledge_cutoff,
+ )
+ return tuple(
+ envelope
+ for row in rows
+ if (envelope := envelope_from_authorized_row(row)) is not None
+ )
+
+
async def load_job_visibility(
conn: asyncpg.Connection, job_id: str, account_id: str
) -> tuple[set[str], set[str], bool, bool]:
@@ -348,7 +410,7 @@ def can_see(row: asyncpg.Record) -> bool:
record_server_failure("global_ask", exc, outcome="internal_error")
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
- "Ask Agent is unavailable: authorized evidence could not be assembled",
+ _ASK_RETRY_MESSAGE,
) from exc
cutoff_text = knowledge_cutoff.isoformat() if knowledge_cutoff else None
grounding_status = ask_grounding_status(sources, cutoff_text)
@@ -366,22 +428,25 @@ def can_see(row: asyncpg.Record) -> bool:
[],
verify_external=verify_external,
client=verification_client,
+ persisted_envelopes=(),
)
delivery = build_ask_delivery("", (), ())
return {
"answer_text": "",
"cited_post_ids": [],
"cited_posts": [],
+ "cited_events": [],
"source_post_ids": [source.post_id for source in sources],
"cited_post_evidence": [],
+ "cited_source_references": [],
"lineage_graph": {"nodes": [], "edges": [], "truncated": False},
"cited_post_images": [],
"external_verification_status": verification_status,
"external_claims": [claim.to_payload() for claim in external_claims],
"next_action": (
- "Review unavailable historical channels before relying on this cutoff answer."
+ "Open the cited posts and compare their retained source text before relying on this answer."
if limitations
- else "No authorized source posts are available for this question."
+ else "Ask about a specific project, person, organization, or time range, then retry."
),
"delivery": delivery,
"knowledge_cutoff": cutoff_text,
@@ -404,7 +469,7 @@ def can_see(row: asyncpg.Record) -> bool:
record_server_failure("global_ask", exc, outcome="provider_unavailable")
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
- "Ask Agent is unavailable: contextual-orchestrator could not complete the answer",
+ _ASK_RETRY_MESSAGE,
) from exc
except (KeyError, ValueError) as exc:
# Contract/schema fault: the orchestrator responded but its payload
@@ -413,7 +478,7 @@ def can_see(row: asyncpg.Record) -> bool:
record_server_failure("global_ask", exc, outcome="provider_unavailable")
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
- "Ask Agent is unavailable: contextual-orchestrator could not complete the answer",
+ _ASK_RETRY_MESSAGE,
) from exc
except Exception as exc:
# Unexpected defect. Keep the customer boundary and emit a full
@@ -422,29 +487,45 @@ def can_see(row: asyncpg.Record) -> bool:
record_server_failure("global_ask", exc, outcome="internal_error")
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
- "Ask Agent is unavailable: contextual-orchestrator could not complete the answer",
+ _ASK_RETRY_MESSAGE,
) from exc
cited_ids = list(answer.cited_post_ids)
+ async with pool.acquire() as conn:
+ persisted_envelopes = (
+ await load_authorized_public_claim_envelopes(
+ conn,
+ cited_ids,
+ knowledge_cutoff=knowledge_cutoff,
+ )
+ if verify_external
+ else ()
+ )
+ if knowledge_cutoff is None:
+ lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids)
+ images = await cited_post_images(conn, cited_ids)
+ else:
+ lineage_graph = {"nodes": [], "edges": [], "truncated": False}
+ images = []
+ source_references = await list_ask_source_references(
+ conn,
+ cited_ids,
+ checked_by=knowledge_cutoff,
+ )
verification_status, external_claims = await _verify_public_claims(
question_text,
usable_sources,
cited_ids,
verify_external=verify_external,
client=verification_client,
+ persisted_envelopes=persisted_envelopes,
)
- if knowledge_cutoff is None:
- async with pool.acquire() as conn:
- lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids)
- images = await cited_post_images(conn, cited_ids)
- else:
- lineage_graph = {"nodes": [], "edges": [], "truncated": False}
- images = []
cited_posts = cited_post_summaries(usable_sources, cited_ids)
+ cited_events = cited_post_events(usable_sources, cited_ids)
cited_evidence = cited_post_evidence(usable_sources, cited_ids)
next_action = _verification_next_action(verification_status)
if knowledge_cutoff is not None:
next_action = (
- "Review unavailable historical channels before relying on this cutoff answer."
+ "Open the cited posts and compare their retained source text before relying on this answer."
if limitations
else "Compare these cutoff-grounded citations with live evidence next."
)
@@ -452,11 +533,18 @@ def can_see(row: asyncpg.Record) -> bool:
"answer_text": answer.answer_text,
"cited_post_ids": cited_ids,
"cited_posts": cited_posts,
+ "cited_events": cited_events,
"cited_post_evidence": cited_evidence,
"cited_post_images": images,
+ "cited_source_references": source_references,
"source_post_ids": [source.post_id for source in sources],
"lineage_graph": lineage_graph,
- "delivery": build_ask_delivery(answer.answer_text, cited_posts, cited_evidence),
+ "delivery": build_ask_delivery(
+ answer.answer_text,
+ cited_posts,
+ cited_evidence,
+ source_references,
+ ),
"external_verification_status": verification_status,
"external_claims": [claim.to_payload() for claim in external_claims],
"next_action": next_action,
@@ -552,7 +640,7 @@ async def process_global_ask_job(
chat_client = chat_factory()
if not chat_client.available:
raise _SafeJobError(
- "Ask Agent is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY"
+ _ASK_RETRY_MESSAGE
)
payload = await asyncio.wait_for(
compute_global_ask_answer(
@@ -588,16 +676,13 @@ async def process_global_ask_job(
# state / missing config) — never a provider-boundary leak.
detail = str(exc)
elif isinstance(exc, asyncio.TimeoutError):
- detail = f"job exceeded the {JOB_DEADLINE_SECONDS}s deadline"
+ detail = _ASK_RETRY_MESSAGE
else:
# Provider responses/exceptions can carry credentials, gateway
# diagnostics, or model output (ADR 0123): never persist the
# raw exception text as a durable `failure_detail`. The
# traceback just logged keeps it for operator debugging only.
- detail = (
- "Ask Agent is unavailable: contextual-orchestrator returned "
- "no complete evidence object"
- )
+ detail = _ASK_RETRY_MESSAGE
async with pool.acquire() as conn:
await conn.execute(
"""
diff --git a/backend/app/main.py b/backend/app/main.py
index 122165990..5bb1f4f56 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -20,19 +20,22 @@
from __future__ import annotations
import asyncio
+import base64
import json
import logging
from contextlib import asynccontextmanager
from dataclasses import asdict
from datetime import date, datetime, timezone
-from typing import Any, Literal
+from typing import Any, AsyncIterator, Literal
from uuid import UUID
import asyncpg
import redis.asyncio as redis
-from fastapi import Depends, FastAPI, HTTPException, Path, Query, status
+from fastapi import Depends, FastAPI, HTTPException, Path, Query, Request, status
from fastapi.middleware.cors import CORSMiddleware
-from pydantic import BaseModel
+from fastapi.responses import JSONResponse, StreamingResponse
+from pydantic import BaseModel, Field
+from starlette.middleware.gzip import GZipMiddleware
from lineageweave.claim_verification import (
NullClaimVerificationClient,
@@ -47,6 +50,7 @@
ticket_created_summary,
ticket_status_changed_summary,
)
+from backend.app.voice_taxonomy import load_voice_taxonomy_summary
from backend.app.affiliate_tree_ingestion import (
fetch_affiliate_forest,
fetch_voc_evidence,
@@ -70,8 +74,7 @@
deliver_queued_analysis_run,
enqueue_pending_analysis_run,
)
-from backend.app.analysis_run_worker import run_analysis_run_worker
-from backend.app.auth import CurrentAccount, get_current_account
+from backend.app.auth import CurrentAccount, get_current_account, warm_oidc_jwks
from backend.app.config import load_settings
from backend.app.customer_hint_ingestion import resolve_customer_hint
from backend.app.db import create_pool, get_pool
@@ -85,9 +88,6 @@
ingest_post_entity_relationships,
)
from backend.app.five_w1h_ingestion import load_five_w1h_slots
-from backend.app.global_ask_queue import (
- run_global_ask_worker,
-)
from backend.app.global_ask_service import read_global_ask_job, submit_global_ask
from backend.app.issue_ticket_ingestion import (
create_ticket,
@@ -140,13 +140,17 @@
persist_post_chat,
)
from backend.app.post_content_queue import (
+ enqueue_post_content_backfill,
ensure_post_content_job,
post_content_api_status,
post_content_is_complete,
publish_post_content_event,
)
-from backend.app.occupational_construct_ingestion import (
- load_occupational_construct_assertions,
+from backend.app.product_catalog_provisioning import (
+ ProductCatalogImport,
+ ProductCatalogParentMissing,
+ ProductCatalogProvisioningConflict,
+ provision_product_catalog_entry,
)
from backend.app.occupational_construct_search import (
OccupationalConstructSearchError,
@@ -155,8 +159,12 @@
search_page_to_payload,
search_visible_occupational_constructs,
)
-from backend.app.post_content_worker import run_post_content_worker
-from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL, source_post_visible
+from backend.app.post_eligibility import (
+ SOURCE_POST_ELIGIBILITY_SQL,
+ source_context_present_sql,
+ source_post_eligibility_sql,
+ source_post_visible,
+)
from backend.app.post_evaluation_ingestion import (
fetch_post_evaluation,
ingest_post_evaluation,
@@ -176,8 +184,15 @@
persist_post_summary,
require_summary_source_body,
)
-from backend.app.ranking_ingestion import load_visible_ranking_posts
+from backend.app.ranking_ingestion import (
+ load_ranking_context_choices,
+ load_selected_ranking_rows,
+)
from backend.app.relation_verification_ingestion import verify_post_relations_from_pool
+from backend.app.source_research_ingestion import (
+ list_source_research_citations,
+ research_post_sources_from_pool,
+)
from backend.app.report_ingestion import (
GROUPING_KINDS,
fetch_period_comparison,
@@ -187,7 +202,7 @@
parse_period_code,
rebuild_period_reports,
)
-from backend.app.source_post_revision import fetch_known_at_revision, parse_as_of_clock
+from backend.app.source_post_revision import parse_as_of_clock
from backend.app.source_post_voice_ingestion import (
PrimaryVoiceAssignmentError,
persist_additional_voice_assignment,
@@ -232,7 +247,7 @@
shutdown_telemetry,
traced,
)
-from lineageweave.ontology import LW
+from lineageweave.ontology import LW, ontology_node_iri
from lineageweave.ontology_neighborhood import (
DEFAULT_MAXIMUM_DEPTH,
DEFAULT_MAXIMUM_EDGES,
@@ -265,11 +280,17 @@
ContextualOrchestratorPostSummaryClient,
NullPostSummaryClient,
)
-from lineageweave.rankweave_client import build_rankweave_client
+from lineageweave.rankweave_client import RankWeaveNotAvailable, build_rankweave_client
from lineageweave.relation_verification import (
NullRelationVerificationClient,
SearxngRelationVerificationClient,
)
+from lineageweave.source_reference_research import (
+ PRIVATE_POST_UNAVAILABLE,
+ VISIBILITY_PUBLIC,
+ NullSourceResearchClient,
+ SearxngOrchestratedSourceResearchClient,
+)
from lineageweave.semantic_hints import customer_hint_trust, format_semantic_hints
from lineageweave.semantic_query import (
ContextualOrchestratorSemanticQueryClient,
@@ -289,66 +310,16 @@ async def lifespan(app: FastAPI):
configure_telemetry("lineageweave")
pool = None
valkey = None
- analysis_worker = None
- content_worker = None
- global_ask_worker = None
try:
settings = load_settings()
+ await warm_oidc_jwks(settings)
pool = await create_pool(settings.database_url)
+ await _warm_post_detail_read_paths(pool)
app.state.pool = pool
valkey = create_valkey_client(settings.valkey_url)
app.state.valkey = valkey
- analysis_worker = asyncio.create_task(
- run_analysis_run_worker(
- valkey,
- pool,
- database_url=settings.database_url,
- tepp_client=configured_tepp_client(
- settings.tepp_transport_url,
- settings.tepp_api_key,
- ),
- adjudication_client=_adjudication_client(),
- )
- )
- app.state.analysis_run_worker = analysis_worker
- content_worker = asyncio.create_task(
- run_post_content_worker(
- valkey,
- pool,
- vision_factory=_vision_client,
- embedding_factory=_embedding_client,
- structure_factory=_post_structure_client,
- )
- )
- app.state.post_content_worker = content_worker
- # Late-bound lambda so tests that monkeypatch _post_chat_client reach
- # the worker too (the name resolves in module globals at call time).
- # Only this worker gets the long answer timeout; the per-post chat
- # endpoint keeps the client's interactive default.
- global_ask_worker = asyncio.create_task(
- run_global_ask_worker(
- valkey,
- pool,
- chat_factory=lambda: _post_chat_client(
- timeout=load_settings().orchestrator_answer_timeout_seconds
- ),
- embedding_factory=_embedding_client,
- semantic_query_factory=_semantic_query_client,
- claim_verification_factory=_claim_verification_client_factory,
- )
- )
- app.state.global_ask_worker = global_ask_worker
yield
finally:
- workers = tuple(
- worker
- for worker in (analysis_worker, content_worker, global_ask_worker)
- if worker is not None
- )
- for worker in workers:
- worker.cancel()
- if workers:
- await asyncio.gather(*workers, return_exceptions=True)
try:
if pool is not None:
await pool.close()
@@ -366,9 +337,10 @@ async def lifespan(app: FastAPI):
app.add_middleware(
CORSMiddleware,
allow_origins=load_settings().frontend_origins,
- allow_methods=["GET", "POST", "PATCH"],
+ allow_methods=["GET", "POST", "PATCH", "PUT"],
allow_headers=["Authorization"],
)
+app.add_middleware(GZipMiddleware)
def _require_post_read(account: CurrentAccount) -> None:
@@ -433,6 +405,27 @@ def _claim_verification_client_factory():
return _claim_verification_client()
+def _source_research_client():
+ """Return the post-scoped public-research client, or its unavailable null."""
+
+ settings = load_settings()
+ if not (
+ settings.searxng_base_url
+ and settings.orchestrator_base_url
+ and settings.orchestrator_api_key
+ and settings.source_research_maximum_leads is not None
+ and settings.source_research_maximum_results is not None
+ ):
+ return NullSourceResearchClient()
+ return SearxngOrchestratedSourceResearchClient(
+ settings.searxng_base_url,
+ settings.orchestrator_base_url,
+ settings.orchestrator_api_key,
+ maximum_leads=settings.source_research_maximum_leads,
+ maximum_results=settings.source_research_maximum_results,
+ )
+
+
def _organization_name_resolution_client():
"""Live orchestrator client when configured; otherwise the unavailable null."""
settings = load_settings()
@@ -601,6 +594,21 @@ def _can_see_post(account: CurrentAccount, post: asyncpg.Record) -> bool:
)
+def _can_see_product_relation_target(
+ account: CurrentAccount, relation: asyncpg.Record
+) -> bool:
+ """Apply ABAC to the normalized target's own evidence post."""
+ return source_post_visible(
+ {
+ "visibility_code": relation["target_visibility_code"],
+ "corporate_entity_id": relation["target_corporate_entity_id"],
+ "process_unit_id": relation["target_process_unit_id"],
+ },
+ account.corporate_entity_ids,
+ account.process_unit_ids,
+ )
+
+
def _is_synthetic_demo_member(member: dict[str, Any], demo_entity_ids: set[str]) -> bool:
"""Identify one pure seed row without hiding real rows sharing its entity."""
return bool(demo_entity_ids) and member["corporate_entity_id"] in demo_entity_ids and not bool(
@@ -761,51 +769,400 @@ async def _load_post_voice_types(
]
+async def _fetch_post_detail_bundle(
+ conn: asyncpg.Connection,
+ post_id: str,
+ as_of_clock: datetime | None,
+ account: CurrentAccount,
+ *,
+ evidence_configured: bool,
+) -> asyncpg.Record | None:
+ """Load one Post's authorized metadata envelope in one database statement."""
+ context_present = source_context_present_sql("source_post")
+ def bundled_eligibility(alias: str) -> str:
+ """Apply the account-scoped corpus mode already computed by the bundle."""
+ return (
+ f"({alias}.source_draft_code is null or btrim({alias}.source_draft_code) = '') and "
+ f"({alias}.source_deleted_flag is null or btrim({alias}.source_deleted_flag) = '') and "
+ "(not (select source_context_required from corpus_mode) or ("
+ f"{source_context_present_sql(alias)}))"
+ )
+
+ evidence_eligible = bundled_eligibility("evidence_post")
+ target_eligible = bundled_eligibility("target_evidence_post")
+ evidence_visible = (
+ "(evidence_post.visibility_code = 'public' or "
+ "(evidence_post.corporate_entity_id = any($3::uuid[]) and "
+ "(cardinality($4::uuid[]) = 0 or evidence_post.process_unit_id = any($4::uuid[]))))"
+ )
+ target_visible = (
+ "(target_evidence_post.visibility_code = 'public' or "
+ "(target_evidence_post.corporate_entity_id = any($3::uuid[]) and "
+ "(cardinality($4::uuid[]) = 0 or target_evidence_post.process_unit_id = any($4::uuid[]))))"
+ )
+ # Safe SQL: immutable schema predicates are interpolated; all request and scope values stay bound.
+ return await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ f"""
+ with corpus_mode as (
+ select exists (
+ select 1 from dashboard_post_read_projection candidate
+ where (candidate.visibility_code = 'public'
+ or candidate.corporate_entity_id = any($3::uuid[]))
+ and candidate.source_context_present
+ ) as source_context_required
+ ), post as (
+ select source_post.*,
+ encode(sha256(convert_to(coalesce(source_post.post_body, ''), 'UTF8')), 'hex')
+ as current_body_sha256
+ from source_post cross join corpus_mode
+ where source_post.post_id = $1
+ and (source_post.source_draft_code is null
+ or btrim(source_post.source_draft_code) = '')
+ and (source_post.source_deleted_flag is null
+ or btrim(source_post.source_deleted_flag) = '')
+ and (not corpus_mode.source_context_required or ({context_present}))
+ ), relations as (
+ select relation.mention_ordinal,
+ jsonb_agg(jsonb_build_object(
+ 'relation_type_code', relation.relation_type_code,
+ 'target_kind_code', relation.target_kind_code,
+ 'target_id', relation.target_id,
+ 'target_label', relation.target_label,
+ 'evidence_text', relation.evidence_text,
+ 'evidence_post_id', relation.evidence_post_id
+ ) order by relation.target_kind_code, relation.target_id) as items
+ from (
+ select relation.mention_ordinal, relation.relation_type_code,
+ 'operations_fact'::text as target_kind_code,
+ 'operations_fact:' || relation.case_kind_code || ':' ||
+ relation.fact_ordinal::text as target_id,
+ fact.value_text as target_label, relation.evidence_text,
+ relation.evidence_post_id
+ from product_operations_fact_relation relation
+ join operations_case_fact fact on fact.post_id = relation.post_id
+ and fact.case_kind_code = relation.case_kind_code
+ and fact.fact_ordinal = relation.fact_ordinal
+ join source_post evidence_post
+ on evidence_post.post_id = relation.evidence_post_id
+ join source_post target_evidence_post
+ on target_evidence_post.post_id = fact.evidence_post_id
+ where relation.post_id = $1 and {evidence_eligible}
+ and {target_eligible} and {evidence_visible} and {target_visible}
+ union all
+ select relation.mention_ordinal, relation.relation_type_code,
+ 'project'::text as target_kind_code,
+ 'project:' || relation.project_key as target_id,
+ project.project_name as target_label, relation.evidence_text,
+ relation.evidence_post_id
+ from product_project_relation relation
+ join post_project_mention project on project.post_id = relation.post_id
+ and project.project_key = relation.project_key
+ join source_post evidence_post
+ on evidence_post.post_id = relation.evidence_post_id
+ where relation.post_id = $1 and {evidence_eligible}
+ and {evidence_visible}
+ ) relation
+ group by relation.mention_ordinal
+ )
+ select post.post_id, post.post_title, null::text as post_body,
+ post.voc_type_code, post.visibility_code,
+ post.source_stage_code, post.source_detail_state_code,
+ post.source_draft_code, post.source_deleted_flag,
+ post.source_author_code, post.source_author_name,
+ post.source_company_code, post.source_company_name,
+ post.source_process_unit_code, post.source_process_unit_name,
+ post.source_sales_pool_code, post.source_sales_pool_name,
+ post.source_customer_code, post.source_customer_name,
+ post.source_project_code, post.source_project_name,
+ post.source_system_code, post.source_record_key,
+ post.corporate_entity_id, post.process_unit_id, post.created_at,
+ post.current_body_sha256,
+ coalesce((select jsonb_object_agg(lookup.lookup_code, lookup.lookup_label)
+ from common_lookup_value lookup
+ where lookup.lookup_code in
+ (post.voc_type_code, post.visibility_code)), '{{}}'::jsonb) as labels,
+ coalesce((select jsonb_agg(jsonb_build_object(
+ 'project_key', project.project_key,
+ 'project_name', project.project_name,
+ 'evidence', project.evidence_text,
+ 'confidence', project.confidence,
+ 'ontology_iri', project.ontology_iri,
+ 'ontology_label', 'Project',
+ 'extraction_method', project.extraction_method,
+ 'resolution_status', 'semantic_candidate',
+ 'provenance', 'post_project_mention.evidence_text'
+ ) order by project.confidence desc, project.project_name,
+ project.project_key)
+ from post_project_mention project where project.post_id = $1),
+ '[]'::jsonb) as project_evidence,
+ coalesce((select jsonb_agg(jsonb_build_object(
+ 'code', voice.voice_type_code,
+ 'label', lookup.lookup_label,
+ 'is_primary', voice.is_primary,
+ 'truth_status_code', voice.truth_status_code,
+ 'evidence_available', voice.provenance_assertion_id is not null
+ ) order by voice.is_primary desc, lookup.display_order,
+ voice.voice_type_code)
+ from source_post_voice voice
+ join common_lookup_value lookup
+ on lookup.lookup_category = 'voc_type'
+ and lookup.lookup_code = voice.voice_type_code
+ where voice.post_id = $1
+ and (($2::timestamptz is null and voice.effective_to is null)
+ or ($2::timestamptz is not null and voice.effective_from <= $2
+ and (voice.effective_to is null or $2 < voice.effective_to)))),
+ '[]'::jsonb) as voice_types,
+ case when $2::timestamptz is not null then '[]'::jsonb else
+ coalesce((select jsonb_agg(jsonb_build_object(
+ 'construct_iri', construct.construct_iri,
+ 'construct_family_code', construct.construct_family_code,
+ 'preferred_label', construct.preferred_label,
+ 'vocabulary_iri', vocabulary.vocabulary_iri,
+ 'vocabulary_version', vocabulary.version_label,
+ 'evidence_text', assertion.evidence_text,
+ 'truth_status_code', assertion.truth_status_code,
+ 'extraction_method', assertion.extraction_method,
+ 'generated_at', assertion.generated_at,
+ 'unit_index', unit.unit_index,
+ 'provenance', 'post_occupational_construct_assertion.evidence_text'
+ ) order by unit.unit_index, construct.construct_family_code,
+ construct.preferred_label, construct.construct_iri)
+ from post_occupational_construct_assertion assertion
+ join occupational_construct construct
+ on construct.construct_id = assertion.construct_id
+ join occupational_construct_vocabulary vocabulary
+ on vocabulary.vocabulary_id = construct.vocabulary_id
+ join post_content_unit unit
+ on unit.post_content_unit_id = assertion.post_content_unit_id
+ join post_occupational_construct_extraction extraction
+ on extraction.post_id = assertion.post_id
+ join post_content_ingestion_job job on job.post_id = assertion.post_id
+ and job.source_body_sha256 = extraction.source_body_sha256
+ where assertion.post_id = $1), '[]'::jsonb) end
+ as occupational_construct_assertions,
+ case when $2::timestamptz is not null then 'historical_unavailable'
+ else coalesce((select case
+ when job.status_code in ('post_content_ingestion_queued',
+ 'post_content_ingestion_running') and $5
+ then 'processing'
+ when extraction.source_body_sha256 = job.source_body_sha256
+ then 'complete'
+ else 'unavailable' end
+ from post_content_ingestion_job job
+ left join post_occupational_construct_extraction extraction
+ on extraction.post_id = job.post_id
+ where job.post_id = $1),
+ case when $5 then 'unavailable' else 'setup_required' end)
+ end as occupational_construct_evidence_status,
+ case when $2::timestamptz is not null then '[]'::jsonb else
+ coalesce((select jsonb_agg(jsonb_build_object(
+ 'mention_ordinal', mention.mention_ordinal,
+ 'extracted_product_name', mention.extracted_product_name,
+ 'resolution_status_code', mention.resolution_status_code,
+ 'canonical_product_name', catalog.canonical_product_name,
+ 'product_catalog_id', catalog.product_catalog_id,
+ 'product_catalog_code', catalog.product_catalog_code,
+ 'product_level_code', catalog.product_level_code,
+ 'evidence_text', mention.evidence_text,
+ 'evidence_post_id', mention.evidence_post_id,
+ 'relations', coalesce(relations.items, '[]'::jsonb)
+ ) order by mention.mention_ordinal)
+ from post_product_mention mention
+ join post_product_analysis product_analysis
+ on product_analysis.post_id = mention.post_id
+ and product_analysis.orchestrator_model_receipt is not null
+ and product_analysis.source_body_sha256 = post.current_body_sha256
+ left join product_catalog catalog
+ on catalog.product_catalog_id = mention.product_catalog_id
+ join source_post evidence_post
+ on evidence_post.post_id = mention.evidence_post_id
+ left join relations on relations.mention_ordinal = mention.mention_ordinal
+ where mention.post_id = $1 and {evidence_eligible}
+ and {evidence_visible}), '[]'::jsonb) end as product_evidence,
+ case when $2::timestamptz is not null then null::jsonb else
+ (select jsonb_build_object(
+ 'analysis_present', exists(select 1 from post_product_analysis analysis
+ where analysis.post_id = $1
+ and analysis.source_body_sha256 = post.current_body_sha256
+ and analysis.orchestrator_model_receipt is not null),
+ 'job_status_code', (select job.status_code
+ from post_content_ingestion_job job
+ where job.post_id = $1
+ and job.source_body_sha256 = post.current_body_sha256
+ order by job.updated_at desc limit 1))) end
+ as product_analysis_state,
+ case when $2::timestamptz is null then null::jsonb else
+ (select jsonb_build_object(
+ 'source_post_revision_id', revision.source_post_revision_id,
+ 'post_title', revision.post_title,
+ 'written_at', revision.written_at,
+ 'as_of', $2::timestamptz)
+ from source_post_revision revision
+ where revision.post_id = $1 and revision.written_at <= $2
+ and (revision.superseded_at is null or revision.superseded_at > $2)
+ order by revision.written_at desc limit 1) end as known_at
+ from post
+ """,
+ post_id,
+ as_of_clock,
+ list(account.corporate_entity_ids),
+ list(account.process_unit_ids),
+ evidence_configured,
+ )
+
+
+async def _warm_post_detail_read_paths(pool: asyncpg.Pool) -> None:
+ """Prepare the exact Post-detail statement on every pooled connection."""
+ account = CurrentAccount(
+ user_account_id="readiness",
+ external_subject_id="readiness",
+ display_name="Readiness",
+ preferred_locale=None,
+ corporate_entity_ids=frozenset(),
+ process_unit_ids=frozenset(),
+ permission_codes=frozenset({"post_read"}),
+ )
+
+ async def warm_one() -> None:
+ """Hold one pool slot while its statement cache is populated."""
+ async with pool.acquire() as conn:
+ await _fetch_post_detail_bundle(
+ conn,
+ "00000000-0000-0000-0000-000000000000",
+ None,
+ account,
+ evidence_configured=False,
+ )
+
+ await asyncio.gather(*(warm_one() for _ in range(pool.get_max_size())))
+
+
async def _post_filter_options(
conn: asyncpg.Connection,
corporate_entity_ids: frozenset[str],
process_unit_ids: frozenset[str],
-) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
- """Return every authorized filter value, not only values on the current page."""
- options_sql = f"""
- select distinct option.lookup_category, option.code,
- coalesce(lookup.lookup_label, option.code) as label,
- coalesce(lookup.display_order, 2147483647) as display_order
- from source_post post
- left join source_post_voice voice
- on voice.post_id = post.post_id and voice.effective_to is null
- cross join lateral (
- values ('post_visibility', post.visibility_code),
- ('voc_type', coalesce(voice.voice_type_code, post.voc_type_code))
- ) as option(lookup_category, code)
- left join common_lookup_value lookup
- on lookup.lookup_category = option.lookup_category
- and lookup.lookup_code = option.code
- where (post.visibility_code = 'public'
- or (post.corporate_entity_id::text = any($1::text[])
- and (cardinality($2::text[]) = 0
- or post.process_unit_id::text = any($2::text[]))))
- and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
- order by option.lookup_category, display_order, option.code
- """
- # Safe SQL: this is a closed lookup statement; entity ids remain asyncpg parameters.
- option_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
- options_sql, list(corporate_entity_ids), list(process_unit_ids)
+ selected_voice_codes: list[str] | None = None,
+ selected_visibility: str | None = None,
+) -> tuple[
+ list[dict[str, str]],
+ list[dict[str, str]],
+ int | None,
+ bool,
+ dict[str, str],
+ list[dict[str, str]],
+]:
+ """Return exact authorized filter values and count from the maintained projection."""
+ rows = await conn.fetch(
+ """
+ with corpus_mode as (
+ select exists (
+ select 1 from voice_taxonomy_day_read_projection candidate
+ where candidate.source_context_present
+ and (candidate.visibility_code = 'public'
+ or candidate.corporate_entity_id = any($1::uuid[]))
+ ) as source_context_required
+ ), filtered_scope as (
+ select scope.*
+ from voice_taxonomy_day_read_projection scope
+ cross join corpus_mode mode
+ where scope.source_context_present = mode.source_context_required
+ and (scope.visibility_code = 'public'
+ or (scope.corporate_entity_id = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or scope.process_unit_key = any($2::uuid[]))))
+ )
+ select scope.visibility_code, sum(scope.total_eligible) as total_eligible,
+ (select array_agg(distinct category.code)
+ from filtered_scope candidate
+ cross join lateral jsonb_object_keys(candidate.category_post_counts)
+ category(code)
+ where candidate.visibility_code = scope.visibility_code) as voice_codes,
+ mode.source_context_required,
+ (select jsonb_object_agg(lookup.lookup_code, lookup.lookup_label)
+ from common_lookup_value lookup
+ where lookup.lookup_category in ('voc_type', 'post_visibility')) as labels,
+ (select jsonb_object_agg(lookup.lookup_code, lookup.display_order)
+ from common_lookup_value lookup
+ where lookup.lookup_category in ('voc_type', 'post_visibility')) as display_orders,
+ (select jsonb_agg(jsonb_build_object(
+ 'code', lookup.lookup_code, 'label', lookup.lookup_label
+ ) order by lookup.display_order, lookup.lookup_code)
+ from common_lookup_value lookup
+ where lookup.lookup_category = 'voc_type') as voice_catalog,
+ case
+ when cardinality($3::text[]) = 0 then (
+ select sum(candidate.total_eligible)
+ from filtered_scope candidate
+ where $4::text is null
+ or candidate.visibility_code = $4
+ )
+ when cardinality($3::text[]) = 1 then (
+ select sum(coalesce(
+ (candidate.category_post_counts ->> $3[1])::bigint, 0
+ ))
+ from filtered_scope candidate
+ where $4::text is null
+ or candidate.visibility_code = $4
+ )
+ end as selected_total_count
+ from filtered_scope scope
+ cross join corpus_mode mode
+ group by scope.visibility_code, mode.source_context_required
+ """,
+ list(corporate_entity_ids),
+ list(process_unit_ids),
+ selected_voice_codes or [],
+ selected_visibility,
+ )
+ labels_value = rows[0]["labels"] if rows else None
+ labels = json.loads(labels_value) if isinstance(labels_value, str) else dict(labels_value or {})
+ display_orders_value = rows[0]["display_orders"] if rows else None
+ display_orders = (
+ json.loads(display_orders_value)
+ if isinstance(display_orders_value, str)
+ else dict(display_orders_value or {})
+ )
+ voice_catalog_value = rows[0]["voice_catalog"] if rows else None
+ voice_catalog = (
+ json.loads(voice_catalog_value)
+ if isinstance(voice_catalog_value, str)
+ else list(voice_catalog_value or [])
+ )
+ voice_codes = sorted(
+ {
+ str(code)
+ for row in rows
+ for code in (row["voice_codes"] or [])
+ }
+ )
+ visibility_codes = sorted(
+ {str(row["visibility_code"]) for row in rows},
+ key=lambda code: (int(display_orders.get(code, 2147483647)), code),
)
return (
- [
- {"code": row["code"], "label": row["label"]}
- for row in option_rows
- if row["lookup_category"] == "voc_type"
- ],
- [
- {"code": row["code"], "label": row["label"]}
- for row in option_rows
- if row["lookup_category"] == "post_visibility"
- ],
+ [{"code": code, "label": labels.get(code, code)} for code in voice_codes],
+ [{"code": code, "label": labels.get(code, code)} for code in visibility_codes],
+ int(rows[0]["selected_total_count"])
+ if rows and rows[0]["selected_total_count"] is not None
+ else None,
+ bool(rows[0]["source_context_required"]) if rows else False,
+ labels,
+ voice_catalog,
)
+@asynccontextmanager
+async def _post_list_query_plan(
+ conn: asyncpg.Connection, *, default_population: bool
+) -> AsyncIterator[None]:
+ """Confine the measured list-search planner settings to one transaction."""
+ async with conn.transaction():
+ await conn.execute("set local plan_cache_mode = 'force_custom_plan'")
+ if not default_population:
+ await conn.execute("set local pg_trgm.similarity_threshold = '0.78'")
+ yield
+
+
@app.get("/api/settings", response_model=dict)
async def read_tenant_settings(
account: CurrentAccount = Depends(get_current_account),
@@ -885,6 +1242,9 @@ async def read_me(
async def operations_dashboard(
period_start: date | None = Query(None),
period_end: date | None = Query(None),
+ external_only: bool = Query(False),
+ case_cursor: str | None = Query(None),
+ case_limit: int = Query(20, ge=1, le=50),
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
@@ -898,6 +1258,10 @@ async def operations_dashboard(
account.process_unit_ids,
period_start,
period_end,
+ external_only,
+ None,
+ case_cursor,
+ case_limit,
)
except ValueError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
@@ -909,12 +1273,164 @@ class LocalePreferenceRequest(BaseModel):
preferred_locale: Literal["en", "ko", "zh", "ja", "vi"]
+class PostContentBackfillRequest(BaseModel):
+ """Bounded operator request for durable semantic-content ingestion."""
+
+ limit: int = Field(default=100, ge=1, le=200)
+
+
+class ProductCatalogProvisionRequest(BaseModel):
+ """One explicit governed product-master source row."""
+
+ preferred_label: str = Field(min_length=1)
+ product_level_code: Literal[
+ "product_group", "product_model", "variant", "trade_item"
+ ]
+ parent_product_code: str | None = None
+ aliases: tuple[str, ...] = ()
+ source_corporate_entity_id: UUID
+ source_system_code: str = Field(pattern=r"^[a-z][a-z0-9_]{0,62}$")
+ source_record_key: str = Field(min_length=1)
+
+
+@app.put("/api/product-catalog/{product_code}")
+async def provision_product_catalog(
+ request: ProductCatalogProvisionRequest,
+ product_code: str = Path(min_length=1),
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, object]:
+ """Provision one source-bound product identity without inferred aliases."""
+ _require_post_admin(account)
+ corporate_entity_id = str(request.source_corporate_entity_id)
+ if corporate_entity_id not in account.corporate_entity_ids:
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "the product source is outside your organization scope",
+ )
+ entry = ProductCatalogImport(
+ product_code=product_code,
+ preferred_label=request.preferred_label,
+ product_level_code=request.product_level_code,
+ parent_product_code=request.parent_product_code,
+ aliases=request.aliases,
+ corporate_entity_id=corporate_entity_id,
+ source_system_code=request.source_system_code,
+ source_record_key=request.source_record_key,
+ )
+ async with pool.acquire() as conn:
+ try:
+ result = await provision_product_catalog_entry(
+ conn,
+ entry,
+ imported_by_account_id=account.user_account_id,
+ )
+ except ProductCatalogParentMissing as exc:
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
+ except ProductCatalogProvisioningConflict as exc:
+ raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
+ return {
+ **result,
+ "product_catalog_code": product_code.strip(),
+ "ontology_iri": ontology_node_iri("product", str(result["product_catalog_id"])),
+ "next_action": "Run product analysis again, then review source evidence and linked products.",
+ }
+
+
+@app.post("/api/post-content/backfill", status_code=status.HTTP_202_ACCEPTED)
+async def queue_post_content_backfill(
+ request: PostContentBackfillRequest,
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+ valkey: redis.Redis = Depends(get_valkey),
+) -> dict[str, int]:
+ """Queue one bounded corpus page and return before semantic work runs."""
+ _require_post_admin(account)
+ settings = load_settings()
+ require_orchestrator_evidence = bool(
+ settings.orchestrator_base_url and settings.orchestrator_api_key
+ )
+ return await enqueue_post_content_backfill(
+ pool,
+ valkey,
+ limit=request.limit,
+ require_embedding=require_orchestrator_evidence,
+ require_structure=require_orchestrator_evidence,
+ )
+
+
class CustomerHintResolveRequest(BaseModel):
"""Body of a POST /api/customer-master/resolve-hint request."""
hint_code: str
+def _customer_master_cursor(value: str | None) -> tuple[int | None, str, str]:
+ """Decode one opaque count-ordered Customer Master continuation."""
+ if value is None:
+ return None, "", ""
+ try:
+ decoded = json.loads(base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)))
+ if not isinstance(decoded, list) or len(decoded) != 3:
+ raise ValueError
+ return int(decoded[0]), str(decoded[1]), str(decoded[2])
+ except (ValueError, TypeError, json.JSONDecodeError) as exc:
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "Invalid customer continuation") from exc
+
+
+def _encode_customer_master_cursor(count: int, first: str, second: str) -> str:
+ """Encode one opaque count-ordered Customer Master continuation."""
+ payload = json.dumps([count, first, second], separators=(",", ":")).encode()
+ return base64.urlsafe_b64encode(payload).decode().rstrip("=")
+
+
+def _author_master_cursor(
+ value: str | None,
+) -> tuple[int | None, int | None, str, UUID | None, str]:
+ """Decode one opaque Keyman-prioritized author continuation."""
+ if value is None:
+ return None, None, "", None, ""
+ try:
+ decoded = json.loads(base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)))
+ if not isinstance(decoded, list) or len(decoded) != 5:
+ raise ValueError
+ return int(decoded[0]), int(decoded[1]), str(decoded[2]), UUID(str(decoded[3])), str(decoded[4])
+ except (ValueError, TypeError, json.JSONDecodeError) as exc:
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "Invalid author continuation") from exc
+
+
+def _encode_author_master_cursor(
+ priority: int, count: int, code: str, account_id: UUID, display_name: str
+) -> str:
+ """Encode the complete stable author-group ordering key."""
+ payload = json.dumps(
+ [priority, count, code, str(account_id), display_name], separators=(",", ":")
+ ).encode()
+ return base64.urlsafe_b64encode(payload).decode().rstrip("=")
+
+
+def _related_post_cursor(value: str | None) -> tuple[datetime | None, UUID | None]:
+ """Decode a related-Post `(created_at, post_id)` continuation."""
+ if value is None:
+ return None, None
+ try:
+ decoded = json.loads(base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)))
+ if not isinstance(decoded, list) or len(decoded) != 2:
+ raise ValueError
+ instant = datetime.fromisoformat(str(decoded[0]).replace("Z", "+00:00"))
+ return instant, UUID(str(decoded[1]))
+ except (ValueError, TypeError, json.JSONDecodeError) as exc:
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "Invalid related-post continuation") from exc
+
+
+def _encode_related_post_cursor(created_at: datetime, post_id: UUID) -> str:
+ """Encode a related-Post continuation without exposing ordering fields."""
+ raw = json.dumps([created_at.isoformat(), str(post_id)], separators=(",", ":")).encode()
+ return base64.urlsafe_b64encode(raw).decode().rstrip("=")
+
+
@app.patch("/api/me/preferences")
async def update_me_preferences(
preference: LocalePreferenceRequest,
@@ -933,174 +1449,160 @@ async def update_me_preferences(
@app.get("/api/customer-master")
async def read_customer_master(
+ customer_cursor: str | None = Query(None),
+ author_cursor: str | None = Query(None),
+ hint_limit: int = Query(20, ge=1, le=50),
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""Return the authorized customer catalog and its cataloged Keymen."""
_require_post_read(account)
+ customer_after = _customer_master_cursor(customer_cursor)
+ author_after = _author_master_cursor(author_cursor)
if not account.corporate_entity_ids:
return {
"corporate_entities": [],
"keymen": [],
"source_customer_hints": [],
"source_author_hints": [],
+ "source_customer_hint_total": 0,
+ "source_author_hint_total": 0,
+ "next_customer_cursor": None,
+ "next_author_cursor": None,
"relationship_network": [],
}
async with pool.acquire() as conn:
- # Safe SQL: the evidence query uses only closed schema fragments; authorized entity ids are bound.
- source_customer_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
- f"""
- with scoped as (
- select post_id, post_title, created_at,
- nullif(btrim(source_customer_code), '') as customer_code,
- nullif(btrim(source_customer_name), '') as customer_name,
- case when nullif(btrim(source_customer_code), '') is null
- then nullif(btrim(source_customer_name), '')
- else null end as customer_name_group
- from source_post
- where (nullif(btrim(source_customer_code), '') is not null
- or nullif(btrim(source_customer_name), '') is not null)
- and (visibility_code = 'public' or (
- corporate_entity_id = any($1::uuid[])
- and (cardinality($2::uuid[]) = 0
- or process_unit_id = any($2::uuid[]))))
- and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}
- ), ranked as (
- select scoped.*,
- row_number() over (
- partition by customer_code, customer_name_group
- order by created_at desc, post_id desc
- ) as related_rank
- from scoped
- ), groups as (
- select customer_code, customer_name_group,
+ source_customer_rows = await conn.fetch(
+ """
+ with visible_groups as (
+ select customer_code_key, customer_name_group_key,
max(customer_name) as customer_name,
- count(*) as post_count
- from ranked
- group by customer_code, customer_name_group
- ), top_groups as materialized (
- select *
- from groups
- order by post_count desc, customer_code, customer_name
- limit 100
- ), related as (
- select ranked.customer_code, ranked.customer_name_group,
- json_agg(
- json_build_object(
- 'post_id', post.post_id::text,
- 'post_title', post.post_title
- )
- order by ranked.created_at desc, ranked.post_id desc
- ) as related_posts
- from ranked
- join top_groups
- on top_groups.customer_code is not distinct from ranked.customer_code
- and top_groups.customer_name_group is not distinct from ranked.customer_name_group
- join source_post post on post.post_id = ranked.post_id
- where ranked.related_rank <= 20
- group by ranked.customer_code, ranked.customer_name_group
+ sum(post_count)::bigint as post_count
+ from customer_hint_group_read_projection
+ where visibility_code = 'public'
+ or (corporate_entity_key = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or process_unit_key = any($2::uuid[])))
+ group by customer_code_key, customer_name_group_key
+ ), counted as (
+ select *, count(*) over ()::bigint as total_count
+ from visible_groups
+ ), page as materialized (
+ select * from counted
+ where $3::bigint is null
+ or post_count < $3
+ or (post_count = $3 and
+ (customer_code_key, customer_name_group_key) > ($4, $5))
+ order by post_count desc, customer_code_key, customer_name_group_key
+ limit $6 + 1
)
- select top_groups.customer_code, top_groups.customer_name, top_groups.post_count,
- coalesce(related.related_posts, '[]'::json) as related_posts
- from top_groups
- left join related
- on related.customer_code is not distinct from top_groups.customer_code
- and related.customer_name_group is not distinct from top_groups.customer_name_group
- order by top_groups.post_count desc, top_groups.customer_code, top_groups.customer_name
+ select page.*
+ from page
+ order by page.post_count desc, page.customer_code_key, page.customer_name_group_key
""",
list(account.corporate_entity_ids),
list(account.process_unit_ids),
+ customer_after[0], customer_after[1], customer_after[2],
+ hint_limit,
)
+ customer_has_more = len(source_customer_rows) > hint_limit
+ source_customer_rows = source_customer_rows[:hint_limit]
# Safe SQL: the evidence query uses only closed schema fragments; authorized entity ids are bound.
source_author_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
f"""
- with scoped as (
- select post.post_id, post.post_title, post.created_at,
- btrim(post.source_author_code) as author_code,
- case
- when post.source_author_name is null
- or btrim(post.source_author_name) = ''
- or lower(btrim(post.source_author_name)) = lower(btrim(post.source_author_code))
- then null
- else btrim(post.source_author_name)
- end as source_author_name,
- post.author_account_id,
- author.display_name as account_display_name
- from source_post post
- join user_account author on author.user_account_id = post.author_account_id
- where post.source_author_code is not null
- and btrim(post.source_author_code) <> ''
+ with groups as (
+ select author_code, author_account_id, account_display_name,
+ max(author_name) as author_name,
+ sum(post_count)::bigint as post_count
+ from author_hint_group_read_projection
+ where visibility_code = 'public'
+ or (corporate_entity_key = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or process_unit_key = any($2::uuid[])))
+ group by author_code, author_account_id, account_display_name
+ ), keyman_authors as (
+ select distinct post.author_code, post.author_account_id,
+ post.account_display_name
+ from post_summary_role role
+ join customer_master_post_read_projection post
+ on post.post_id = role.post_id
and (post.visibility_code = 'public' or (
post.corporate_entity_id = any($1::uuid[])
and (cardinality($2::uuid[]) = 0
or post.process_unit_id = any($2::uuid[]))))
- and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
- ), ranked as (
- select scoped.*,
- row_number() over (
- partition by author_code, author_account_id, account_display_name
- order by created_at desc, post_id desc
- ) as related_rank
- from scoped
- ), groups as (
- select author_code, author_account_id, account_display_name,
- max(source_author_name) as author_name,
- count(*) as post_count
- from ranked
- group by author_code, author_account_id, account_display_name
- ), keyman_mentions as (
- select ranked.author_code, ranked.author_account_id,
- ranked.account_display_name, ranked.post_id,
- person.person_id, person.person_name,
- person.person_side_code, person.last_known_job_title
- from ranked
- join post_summary_role role
- on role.post_id = ranked.post_id
- and role.actor_type_code = 'prov_person'
join cataloged_person person
on person.person_id = role.cataloged_person_id
and person.person_side_code = 'our_side'
- where role.cataloged_person_id is not null
+ where post.author_code is not null
+ and role.actor_type_code = 'prov_person'
+ and role.cataloged_person_id is not null
union
- select ranked.author_code, ranked.author_account_id,
- ranked.account_display_name, ranked.post_id,
- person.person_id, person.person_name,
- person.person_side_code, person.last_known_job_title
- from ranked
- join post_person_mention mention
- on mention.post_id = ranked.post_id
+ select distinct post.author_code, post.author_account_id,
+ post.account_display_name
+ from post_person_mention mention
+ join customer_master_post_read_projection post
+ on post.post_id = mention.post_id
+ and (post.visibility_code = 'public' or (
+ post.corporate_entity_id = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or post.process_unit_id = any($2::uuid[]))))
join cataloged_person person
on person.person_id = mention.person_id
and person.person_side_code = 'our_side'
- ), keyman_authors as (
- select distinct author_code, author_account_id, account_display_name
- from keyman_mentions
- ), top_groups as materialized (
- select groups.*
+ where post.author_code is not null
+ ), ordered_groups as (
+ select groups.*,
+ (keyman_authors.author_code is not null)::integer as keyman_priority,
+ count(*) over ()::bigint as total_count
from groups
left join keyman_authors
on keyman_authors.author_code = groups.author_code
and keyman_authors.author_account_id = groups.author_account_id
and keyman_authors.account_display_name = groups.account_display_name
- order by (keyman_authors.author_code is not null) desc,
- groups.post_count desc, groups.author_code
- limit 100
+ ), top_groups as materialized (
+ select * from ordered_groups
+ where $3::integer is null
+ or keyman_priority < $3
+ or (keyman_priority = $3 and post_count < $4)
+ or (keyman_priority = $3 and post_count = $4 and
+ (author_code, author_account_id, account_display_name) > ($5, $6, $7))
+ order by keyman_priority desc, post_count desc, author_code,
+ author_account_id, account_display_name
+ limit $8 + 1
), keyman_groups as (
- select mentions.author_code, mentions.author_account_id,
- mentions.account_display_name,
- mentions.person_id, mentions.person_name,
- mentions.person_side_code, mentions.last_known_job_title,
- count(distinct mentions.post_id) as mention_count
- from keyman_mentions mentions
- join top_groups
- on top_groups.author_code = mentions.author_code
- and top_groups.author_account_id = mentions.author_account_id
- and top_groups.account_display_name = mentions.account_display_name
- group by mentions.author_code, mentions.author_account_id,
- mentions.account_display_name, mentions.person_id,
- mentions.person_name, mentions.person_side_code,
- mentions.last_known_job_title
+ select post.author_code, post.author_account_id,
+ post.account_display_name, person.person_id,
+ person.person_name, person.person_side_code,
+ person.last_known_job_title,
+ count(distinct post.post_id) as mention_count
+ from top_groups
+ join customer_master_post_read_projection post
+ on post.author_code = top_groups.author_code
+ and post.author_account_id = top_groups.author_account_id
+ and post.account_display_name = top_groups.account_display_name
+ and (post.visibility_code = 'public' or (
+ post.corporate_entity_id = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or post.process_unit_id = any($2::uuid[]))))
+ join lateral (
+ select role.cataloged_person_id as person_id
+ from post_summary_role role
+ where role.post_id = post.post_id
+ and role.actor_type_code = 'prov_person'
+ and role.cataloged_person_id is not null
+ union
+ select mention.person_id
+ from post_person_mention mention
+ where mention.post_id = post.post_id
+ ) evidence on true
+ join cataloged_person person
+ on person.person_id = evidence.person_id
+ and person.person_side_code = 'our_side'
+ group by post.author_code, post.author_account_id,
+ post.account_display_name, person.person_id,
+ person.person_name, person.person_side_code,
+ person.last_known_job_title
), keyman_related as (
select author_code, author_account_id, account_display_name,
json_agg(
@@ -1117,42 +1619,27 @@ async def read_customer_master(
) as keyman_hints
from keyman_groups
group by author_code, author_account_id, account_display_name
- ), related as (
- select ranked.author_code, ranked.author_account_id, ranked.account_display_name,
- json_agg(
- json_build_object(
- 'post_id', post.post_id::text,
- 'post_title', post.post_title
- )
- order by ranked.created_at desc, ranked.post_id desc
- ) as related_posts
- from ranked
- join top_groups
- on top_groups.author_code = ranked.author_code
- and top_groups.author_account_id = ranked.author_account_id
- and top_groups.account_display_name = ranked.account_display_name
- join source_post post on post.post_id = ranked.post_id
- where ranked.related_rank <= 20
- group by ranked.author_code, ranked.author_account_id, ranked.account_display_name
)
select top_groups.author_code, top_groups.author_name, top_groups.author_account_id,
top_groups.account_display_name, top_groups.post_count,
- coalesce(keyman_related.keyman_hints, '[]'::json) as keyman_hints,
- coalesce(related.related_posts, '[]'::json) as related_posts
+ top_groups.keyman_priority, top_groups.total_count,
+ coalesce(keyman_related.keyman_hints, '[]'::json) as keyman_hints
from top_groups
left join keyman_related
on keyman_related.author_code = top_groups.author_code
and keyman_related.author_account_id = top_groups.author_account_id
and keyman_related.account_display_name = top_groups.account_display_name
- left join related
- on related.author_code = top_groups.author_code
- and related.author_account_id = top_groups.author_account_id
- and related.account_display_name = top_groups.account_display_name
- order by top_groups.post_count desc, top_groups.author_code
+ order by top_groups.keyman_priority desc,
+ top_groups.post_count desc, top_groups.author_code,
+ top_groups.author_account_id, top_groups.account_display_name
""",
list(account.corporate_entity_ids),
list(account.process_unit_ids),
+ author_after[0], author_after[1], author_after[2], author_after[3],
+ author_after[4], hint_limit,
)
+ author_has_more = len(source_author_rows) > hint_limit
+ source_author_rows = source_author_rows[:hint_limit]
entity_rows = await conn.fetch(
"""
select corporate_entity_id, corporate_entity_code, entity_name,
@@ -1248,16 +1735,16 @@ async def read_customer_master(
"keymen": list(keymen_by_id.values()),
"source_customer_hints": [
{
- "customer_code": row["customer_code"],
+ "customer_code": row["customer_code_key"] or None,
"customer_name": row["customer_name"],
"post_count": row["post_count"],
- "related_posts": (
- json.loads(row["related_posts"])
- if isinstance(row["related_posts"], str)
- else row["related_posts"] or []
- ),
+ "related_posts": [],
+ "related_posts_next_cursor": None,
+ "related_posts_loaded": False,
"resolution_status": "hint_only",
- "hint_trust": customer_hint_trust(row["customer_name"], row["customer_code"]),
+ "hint_trust": customer_hint_trust(
+ row["customer_name"], row["customer_code_key"] or None
+ ),
"provenance": "source_post.source_customer_code/source_post.source_customer_name",
}
for row in source_customer_rows
@@ -1277,11 +1764,9 @@ async def read_customer_master(
if isinstance(row["keyman_hints"], str)
else row["keyman_hints"] or []
),
- "related_posts": (
- json.loads(row["related_posts"])
- if isinstance(row["related_posts"], str)
- else row["related_posts"] or []
- ),
+ "related_posts": [],
+ "related_posts_next_cursor": None,
+ "related_posts_loaded": False,
"resolution_status": (
"our_side_context_only"
if source_author_affiliations.get(str(row["author_account_id"]), [])
@@ -1295,6 +1780,91 @@ async def read_customer_master(
for row in source_author_rows
],
"relationship_network": relationship_network,
+ "source_customer_hint_total": (
+ int(source_customer_rows[0]["total_count"]) if source_customer_rows else 0
+ ),
+ "source_author_hint_total": (
+ int(source_author_rows[0]["total_count"]) if source_author_rows else 0
+ ),
+ "next_customer_cursor": (
+ _encode_customer_master_cursor(
+ int(source_customer_rows[-1]["post_count"]),
+ str(source_customer_rows[-1]["customer_code_key"]),
+ str(source_customer_rows[-1]["customer_name_group_key"]),
+ ) if customer_has_more and source_customer_rows else None
+ ),
+ "next_author_cursor": (
+ _encode_author_master_cursor(
+ int(source_author_rows[-1]["keyman_priority"]),
+ int(source_author_rows[-1]["post_count"]),
+ str(source_author_rows[-1]["author_code"]),
+ source_author_rows[-1]["author_account_id"],
+ str(source_author_rows[-1]["account_display_name"]),
+ ) if author_has_more and source_author_rows else None
+ ),
+ }
+
+
+@app.get("/api/customer-master/related-posts")
+async def read_customer_master_related_posts(
+ kind: Literal["customer", "author"] = Query(...),
+ customer_code: str | None = Query(None),
+ customer_name: str | None = Query(None),
+ author_code: str | None = Query(None),
+ author_account_id: UUID | None = Query(None),
+ account_display_name: str | None = Query(None),
+ cursor: str | None = Query(None),
+ limit: int = Query(20, ge=1, le=50),
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, Any]:
+ """Continue one authorized customer or author hint's related Posts."""
+ _require_post_read(account)
+ after = _related_post_cursor(cursor)
+ if kind == "customer":
+ code_key = (customer_code or "").strip()
+ name_key = "" if code_key else (customer_name or "").strip()
+ if not code_key and not name_key:
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "Customer hint identity is required")
+ identity_sql = "customer_code_key = $3 and customer_name_group_key = $4"
+ identity_values: tuple[Any, ...] = (code_key, name_key)
+ else:
+ if not author_code or author_account_id is None or not account_display_name:
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "Author hint identity is required")
+ identity_sql = "author_code = $3 and author_account_id = $4 and account_display_name = $5"
+ identity_values = (author_code.strip(), author_account_id, account_display_name)
+ cursor_offset = 3 + len(identity_values)
+ sql = f"""
+ select post_id, post_title, created_at
+ from customer_master_post_read_projection
+ where {identity_sql}
+ and (visibility_code = 'public'
+ or (corporate_entity_id = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or process_unit_id = any($2::uuid[]))))
+ and (${cursor_offset}::timestamptz is null
+ or (created_at, post_id) < (${cursor_offset}, ${cursor_offset + 1}))
+ order by created_at desc, post_id desc
+ limit ${cursor_offset + 2} + 1
+ """
+ async with pool.acquire() as conn:
+ # Safe SQL: identity_sql contains only closed schema predicates; all values are bound.
+ rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ sql,
+ list(account.corporate_entity_ids), list(account.process_unit_ids),
+ *identity_values, after[0], after[1], limit,
+ )
+ has_more = len(rows) > limit
+ rows = rows[:limit]
+ return {
+ "related_posts": [
+ {"post_id": str(row["post_id"]), "post_title": row["post_title"]}
+ for row in rows
+ ],
+ "next_cursor": (
+ _encode_related_post_cursor(rows[-1]["created_at"], rows[-1]["post_id"])
+ if has_more and rows else None
+ ),
}
@@ -1398,7 +1968,7 @@ async def rebuild_lineage_graph(
@app.get("/api/posts")
async def list_posts(
- limit: int = Query(50, ge=1, le=200),
+ limit: int = Query(20, ge=1, le=50),
offset: int = Query(0, ge=0),
search: str | None = Query(None, max_length=200),
voc_type: list[str] | None = Query(None, max_length=80),
@@ -1406,199 +1976,154 @@ async def list_posts(
sort: Literal["newest", "oldest", "title"] = Query("newest"),
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
-) -> dict[str, Any]:
+) -> JSONResponse:
"""List authorized posts, with semantic evidence search when requested."""
_require_post_read(account)
search_term = search.strip() if search and search.strip() else None
+ voice_filters = [code.strip() for code in voc_type if code.strip()] if voc_type else []
+ visibility_filter = visibility.strip() if visibility and visibility.strip() else None
async with pool.acquire() as conn:
- voc_type_options, visibility_options = await _post_filter_options(
- conn, account.corporate_entity_ids, account.process_unit_ids
+ (
+ voc_type_options,
+ visibility_options,
+ projected_total_count,
+ source_context_required,
+ labels,
+ voice_type_catalog,
+ ) = await _post_filter_options(
+ conn,
+ account.corporate_entity_ids,
+ account.process_unit_ids,
+ voice_filters,
+ visibility_filter,
)
- voice_type_catalog = [
- {"code": row["lookup_code"], "label": row["lookup_label"]}
- for row in await conn.fetch(
- """
- select lookup_code, lookup_label
- from common_lookup_value
- where lookup_category = 'voc_type'
- order by display_order, lookup_code
- """
- )
- ]
+ search_candidate_ids: list[str] = []
body_search_ids: list[str] = []
if search_term:
- # Safe SQL: search SQL is a closed schema query; search_term is bound through $1.
- body_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ # Safe SQL: candidate SQL is closed schema text; every request value is bound.
+ candidate_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
f"""
- select post_id
- from source_post
- where {SOURCE_POST_ELIGIBILITY_SQL.format(alias="source_post")}
- and (lower(left(source_post_search_text(post_body), 16384))
- like '%' || lower($1) || '%'
- or to_tsvector('simple', source_post_search_text(post_body))
- @@ plainto_tsquery('simple', $1))
- order by
- case when lower(left(source_post_search_text(post_body), 16384))
- like '%' || lower($1) || '%' then 0 else 1 end,
- ts_rank(
- to_tsvector('simple', source_post_search_text(post_body)),
- plainto_tsquery('simple', $1)
- ) desc,
- post_id
+ with matched as (
+ select projection.post_id,
+ true as body_match, 0 as body_priority,
+ ts_rank(projection.post_body_search_vector,
+ plainto_tsquery('simple', $1)) as body_rank
+ from post_list_read_projection projection
+ where projection.post_body_search_prefix like '%' || lower($1) || '%'
+ union all
+ select projection.post_id, true, 1,
+ ts_rank(projection.post_body_search_vector,
+ plainto_tsquery('simple', $1))
+ from post_list_read_projection projection
+ where projection.post_body_search_vector @@ plainto_tsquery('simple', $1)
+ union all
+ select projection.post_id, false, 2, 0::real
+ from post_list_read_projection projection
+ where projection.search_source_exact_text like '%' || lower($1) || '%'
+ union all
+ select projection.post_id, false, 2, 0::real
+ from post_list_read_projection projection
+ where projection.search_related_master_exact_text like '%' || lower($1) || '%'
+ union all
+ select projection.post_id, false, 2, 0::real
+ from post_list_read_projection projection
+ where char_length($1) >= 3
+ and projection.search_normalized_post_id % lower($1)
+ and similarity(projection.search_normalized_post_id, lower($1)) >= 0.78
+ union all
+ select projection.post_id, false, 2, 0::real
+ from post_list_read_projection projection
+ where char_length($1) >= 3
+ and projection.search_source_record_key % lower($1)
+ and similarity(projection.search_source_record_key, lower($1)) >= 0.78
+ ), candidate as (
+ select post_id, bool_or(body_match) as body_match,
+ min(body_priority) as body_priority,
+ max(body_rank) as body_rank
+ from matched
+ group by post_id
+ ), authorized as (
+ select candidate.post_id, candidate.body_match,
+ candidate.body_priority, candidate.body_rank
+ from candidate
+ join source_post source on source.post_id = candidate.post_id
+ where (source.visibility_code = 'public'
+ or (source.corporate_entity_id::text = any($2::text[])
+ and (cardinality($3::text[]) = 0
+ or source.process_unit_id::text = any($3::text[]))))
+ and {source_post_eligibility_sql('source', source_context_required=source_context_required)}
+ )
+ select authorized.post_id, authorized.body_match,
+ count(*) over() as total_count
+ from authorized
+ join source_post source on source.post_id = authorized.post_id
+ where ($4::text[] is null or exists (
+ select 1 from source_post_voice voice_filter
+ where voice_filter.post_id = source.post_id
+ and voice_filter.effective_to is null
+ and voice_filter.voice_type_code = any($4::text[])
+ ))
+ and ($5::text is null or source.visibility_code = $5)
+ order by
+ case
+ when lower(coalesce(source.post_title, ''))
+ like '%' || lower($1) || '%' then 0
+ when authorized.body_match then 1
+ else 2
+ end,
+ case when authorized.body_match then authorized.body_priority end,
+ case when authorized.body_match then authorized.body_rank end desc,
+ case when $8::text = 'title' then lower(coalesce(source.post_title, '')) end,
+ case when $8::text = 'oldest' then source.created_at end,
+ case when $8::text in ('newest', 'title') then source.created_at end desc,
+ source.post_id desc
+ offset $6 limit $7
""",
search_term,
+ list(account.corporate_entity_ids),
+ list(account.process_unit_ids),
+ voice_filters or None,
+ visibility_filter,
+ offset,
+ limit,
+ sort,
)
- body_search_ids = [str(row["post_id"]) for row in body_rows]
- # Safe SQL: page SQL is a closed schema query; every request value is an asyncpg parameter.
- rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ search_candidate_ids = [str(row["post_id"]) for row in candidate_rows]
+ body_search_ids = [
+ str(row["post_id"]) for row in candidate_rows if row["body_match"]
+ ]
+ search_total_count = (
+ int(candidate_rows[0]["total_count"]) if candidate_rows else 0
+ )
+ else:
+ search_total_count = None
+ uses_projected_population = search_term is None and projected_total_count is not None
+ total_count_sql = (
+ "0::bigint"
+ if uses_projected_population or search_total_count is not None
+ else "count(*) over()"
+ )
+ async def fetch_page() -> list[asyncpg.Record]:
+ """Execute the closed post-page query with bound request values."""
+ # Safe SQL: page SQL is closed schema text; every request value is bound.
+ return await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
f"""
with page as (
- select post.post_id, post.post_title, post.voc_type_code, post.visibility_code,
- post.source_stage_code, post.source_detail_state_code,
- post.source_draft_code, post.source_deleted_flag,
- post.source_author_code, post.source_author_name,
- post.source_company_code, post.source_company_name,
- post.source_process_unit_code, post.source_process_unit_name,
- post.source_sales_pool_code, post.source_sales_pool_name,
- post.source_customer_code, post.source_customer_name,
- post.source_project_code, post.source_project_name,
- post.source_system_code,
- post.source_record_key,
- post.corporate_entity_id, post.process_unit_id, post.created_at,
+ select post.post_id, post.post_title, post.created_at,
case
when $1::text is null then 0
when lower(coalesce(post.post_title, '')) like '%' || lower($1) || '%' then 0
when post.post_id = any($5::uuid[]) then 1
else 2
end as search_priority,
- count(*) over() as total_count
+ {total_count_sql} as total_count
from source_post post
where (post.visibility_code = 'public'
or (post.corporate_entity_id::text = any($2::text[])
and (cardinality($9::text[]) = 0
or post.process_unit_id::text = any($9::text[]))))
- and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")}
- and (
- $1::text is null
- or post.post_title ilike '%' || $1 || '%'
- or post.thread_group_key ilike '%' || $1 || '%'
- or post.secondary_grouping_key ilike '%' || $1 || '%'
- or concat_ws(' ',
- post.source_stage_code,
- post.source_detail_state_code,
- post.source_draft_code,
- post.source_deleted_flag,
- post.source_author_code,
- post.source_author_name,
- post.source_company_code,
- post.source_company_name,
- post.source_process_unit_code,
- post.source_process_unit_name,
- post.source_sales_pool_code,
- post.source_sales_pool_name,
- post.source_customer_code,
- post.source_customer_name,
- post.source_project_code,
- post.source_project_name,
- post.source_system_code,
- post.source_record_key
- ) ilike '%' || $1 || '%'
- or replace(post.post_id::text, '-', '') ilike '%' || lower($1) || '%'
- or (
- char_length($1) >= 3
- and (
- similarity(replace(post.post_id::text, '-', ''), lower($1)) >= 0.78
- or similarity(lower(coalesce(post.source_record_key, '')), lower($1)) >= 0.78
- or word_similarity(lower($1), lower(post.post_title)) >= 0.45
- or word_similarity(lower($1), lower(post.secondary_grouping_key)) >= 0.45
- or word_similarity(
- lower($1),
- lower(concat_ws(' ',
- post.source_stage_code,
- post.source_detail_state_code,
- post.source_draft_code,
- post.source_deleted_flag,
- post.source_author_code,
- post.source_author_name,
- post.source_company_code,
- post.source_company_name,
- post.source_process_unit_code,
- post.source_process_unit_name,
- post.source_sales_pool_code,
- post.source_sales_pool_name,
- post.source_customer_code,
- post.source_customer_name,
- post.source_project_code,
- post.source_project_name
- ))
- ) >= 0.45
- )
- )
- or post.post_id = any($5::uuid[])
- or exists (
- select 1 from post_project_mention project
- where project.post_id = post.post_id
- and (project.project_name ilike '%' || $1 || '%'
- or project.evidence_text ilike '%' || $1 || '%'
- or project.ontology_iri ilike '%' || $1 || '%'
- or (char_length($1) >= 3 and word_similarity(lower($1), lower(project.project_name)) >= 0.45))
- )
- or exists (
- select 1 from post_summary_role role
- where role.post_id = post.post_id
- and (role.actor_name ilike '%' || $1 || '%'
- or role.responsibility ilike '%' || $1 || '%'
- or coalesce(role.affiliated_organization_name, '') ilike '%' || $1 || '%'
- or (char_length($1) >= 3 and word_similarity(lower($1), lower(role.actor_name)) >= 0.45))
- )
- or exists (
- select 1
- from post_person_mention mention
- join cataloged_person person on person.person_id = mention.person_id
- where mention.post_id = post.post_id
- and (
- person.person_name ilike '%' || $1 || '%'
- or (char_length($1) >= 3 and word_similarity(lower($1), lower(person.person_name)) >= 0.45)
- )
- )
- or exists (
- select 1 from post_summary_result summary
- where summary.post_id = post.post_id
- and summary.korean_summary ilike '%' || $1 || '%'
- )
- or exists (
- select 1 from post_summary_event event
- where event.post_id = post.post_id
- and event.event_text ilike '%' || $1 || '%'
- )
- or exists (
- select 1 from corporate_entity customer
- where customer.corporate_entity_id = post.corporate_entity_id
- and (customer.entity_name ilike '%' || $1 || '%'
- or customer.corporate_entity_code ilike '%' || $1 || '%')
- )
- or exists (
- select 1 from process_unit process
- where process.process_unit_id = post.process_unit_id
- and (process.process_unit_name ilike '%' || $1 || '%'
- or process.process_unit_code ilike '%' || $1 || '%')
- )
- or exists (
- select 1 from user_account author
- where author.user_account_id = post.author_account_id
- and (author.display_name ilike '%' || $1 || '%'
- or author.email_address ilike '%' || $1 || '%')
- )
- or exists (
- select 1
- from account_affiliation affiliation
- join corporate_entity affiliated
- on affiliated.corporate_entity_id = affiliation.corporate_entity_id
- where affiliation.user_account_id = post.author_account_id
- and (affiliated.entity_name ilike '%' || $1 || '%'
- or affiliated.corporate_entity_code ilike '%' || $1 || '%')
- )
- )
+ and {source_post_eligibility_sql('post', source_context_required=source_context_required)}
+ and ($1::text is null or post.post_id = any($10::uuid[]))
and ($3::text[] is null or exists (
select 1 from source_post_voice voice_filter
where voice_filter.post_id = post.post_id
@@ -1607,6 +2132,10 @@ async def list_posts(
))
and ($4::text is null or post.visibility_code = $4)
order by
+ case
+ when $1::text is not null
+ then array_position($10::uuid[], post.post_id)
+ end asc,
search_priority asc,
case
when $1::text is not null and post.post_id = any($5::uuid[])
@@ -1619,26 +2148,38 @@ async def list_posts(
offset $6
limit $7
)
- select page.*,
+ select post.post_id, post.post_title, post.voc_type_code, post.visibility_code,
+ post.source_stage_code, post.source_detail_state_code,
+ post.source_draft_code, post.source_deleted_flag,
+ post.source_author_code, post.source_author_name,
+ post.source_company_code, post.source_company_name,
+ post.source_process_unit_code, post.source_process_unit_name,
+ post.source_sales_pool_code, post.source_sales_pool_name,
+ post.source_customer_code, post.source_customer_name,
+ post.source_project_code, post.source_project_name,
+ post.source_system_code, post.source_record_key,
+ post.corporate_entity_id, post.process_unit_id, post.created_at,
+ page.search_priority, page.total_count,
case
- when $1::text is not null
- and strpos(lower(source_post_search_text(post.post_body)), lower($1)) > 0
+ when $1::text is null then projection.post_body_excerpt
+ when strpos(projection.post_body_search_prefix, lower($1)) > 0
then btrim(substring(
- source_post_search_text(post.post_body)
+ projection.post_body_search_prefix
from greatest(
1,
- strpos(lower(source_post_search_text(post.post_body)), lower($1)) - 140
+ strpos(projection.post_body_search_prefix, lower($1)) - 140
) for 420
))
- else btrim(left(source_post_search_text(post.post_body), 420))
+ else projection.post_body_excerpt
end as post_body_excerpt,
- char_length(coalesce(post.post_body, '')) > 420 as post_body_truncated,
+ projection.post_body_truncated,
coalesce(projects.project_evidence, '[]'::json) as project_evidence,
coalesce(voices.voice_types, '[]'::json) as voice_types
from page
join source_post post on post.post_id = page.post_id
- left join lateral (
- select json_agg(
+ join post_list_read_projection projection on projection.post_id = page.post_id
+ left join (
+ select project.post_id, json_agg(
json_build_object(
'project_key', project.project_key,
'project_name', project.project_name,
@@ -1653,16 +2194,22 @@ async def list_posts(
order by project.confidence desc, project.project_name, project.project_key
) as project_evidence
from (
- select project_key, project_name, evidence_text, confidence,
- ontology_iri, extraction_method
- from post_project_mention
- where post_id = page.post_id
- order by confidence desc, project_name, project_key
- limit 5
+ select candidate.*,
+ row_number() over (
+ partition by candidate.post_id
+ order by candidate.confidence desc,
+ candidate.project_name,
+ candidate.project_key
+ ) as project_rank
+ from post_project_mention candidate
+ join page project_page
+ on project_page.post_id = candidate.post_id
) project
- ) projects on true
- left join lateral (
- select json_agg(
+ where project.project_rank <= 5
+ group by project.post_id
+ ) projects on projects.post_id = page.post_id
+ left join (
+ select voice.post_id, json_agg(
json_build_object(
'code', voice.voice_type_code,
'label', lookup.lookup_label,
@@ -1677,10 +2224,15 @@ async def list_posts(
join common_lookup_value lookup
on lookup.lookup_category = 'voc_type'
and lookup.lookup_code = voice.voice_type_code
- where voice.post_id = page.post_id
- and voice.effective_to is null
- ) voices on true
+ join page voice_page on voice_page.post_id = voice.post_id
+ where voice.effective_to is null
+ group by voice.post_id
+ ) voices on voices.post_id = page.post_id
order by
+ case
+ when $1::text is not null
+ then array_position($10::uuid[], page.post_id)
+ end asc,
case when $1::text is not null then page.search_priority end asc,
case
when $1::text is not null and page.search_priority = 1
@@ -1693,18 +2245,33 @@ async def list_posts(
""",
search_term,
list(account.corporate_entity_ids),
- [code.strip() for code in voc_type if code.strip()] if voc_type else None,
- visibility.strip() if visibility and visibility.strip() else None,
+ voice_filters or None,
+ visibility_filter,
body_search_ids,
- offset,
+ 0 if search_term else offset,
limit,
sort,
list(account.process_unit_ids),
+ search_candidate_ids,
)
+ # Nullable search/filter parameters keep the wire contract stable, but
+ # their generic plan measured 42--64 ms versus 4.4 ms for the exact
+ # custom plan. The context limits that measured exception to one tx.
+ async with _post_list_query_plan(
+ conn, default_population=search_term is None
+ ):
+ rows = await fetch_page()
visible = [row for row in rows if _can_see_post(account, row)]
- labels = await _lookup_post_labels(conn, visible)
- total_count = int(rows[0]["total_count"]) if rows else 0
- return {
+ total_count = (
+ projected_total_count
+ if uses_projected_population
+ else search_total_count
+ if search_total_count is not None
+ else int(rows[0]["total_count"])
+ if rows
+ else 0
+ )
+ return JSONResponse({
"posts": [_serialize_post(row, labels) for row in visible],
"total_count": total_count,
"limit": limit,
@@ -1712,25 +2279,93 @@ async def list_posts(
"voc_type_options": voc_type_options,
"voice_type_catalog": voice_type_catalog,
"visibility_options": visibility_options,
+ })
+
+
+@app.get("/api/voice-taxonomy/summary")
+async def read_voice_taxonomy_summary(
+ date_from: date | None = None,
+ date_to: date | None = None,
+ corporate_entity_id: UUID | None = None,
+ process_unit_id: UUID | None = None,
+ team_id: UUID | None = None,
+ person_id: UUID | None = None,
+ product_catalog_id: UUID | None = None,
+ project_key: str | None = Query(default=None, max_length=200),
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, Any]:
+ """Return overlapping source and derived Voice counts for the selected scope."""
+ _require_post_read(account)
+ if date_from is not None and date_to is not None and date_to < date_from:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_CONTENT,
+ "Choose an end time after the start time, then review the updated scope.",
+ )
+ async with pool.acquire() as conn:
+ excluded_entity_ids: tuple[str, ...] = ()
+ source_context_required = await has_real_source_context(
+ conn, list(account.corporate_entity_ids)
+ )
+ if source_context_required:
+ excluded_entity_ids = tuple(sorted(await fetch_demo_corporate_entity_ids(conn)))
+ summary = await load_voice_taxonomy_summary(
+ conn,
+ authorized_corporate_entity_ids=tuple(str(value) for value in account.corporate_entity_ids),
+ authorized_process_unit_ids=tuple(str(value) for value in account.process_unit_ids),
+ date_from=date_from,
+ date_to=date_to,
+ corporate_entity_id=str(corporate_entity_id) if corporate_entity_id else None,
+ process_unit_id=str(process_unit_id) if process_unit_id else None,
+ team_id=str(team_id) if team_id else None,
+ person_id=str(person_id) if person_id else None,
+ product_catalog_id=str(product_catalog_id) if product_catalog_id else None,
+ project_key=project_key.strip() if project_key and project_key.strip() else None,
+ excluded_corporate_entity_ids=excluded_entity_ids,
+ source_context_required=source_context_required,
+ )
+ total = int(summary["total_eligible"])
+ raw_category_counts = summary["category_post_counts"]
+ category_counts = (
+ json.loads(raw_category_counts)
+ if isinstance(raw_category_counts, str)
+ else dict(raw_category_counts)
+ )
+ return {
+ **{key: value for key, value in summary.items() if key != "category_post_counts"},
+ "category_memberships": [
+ {
+ "voice_concept_code": code,
+ "post_count": int(count),
+ "eligible_percentage": (float(count) / total * 100.0) if total else 0.0,
+ }
+ for code, count in sorted(category_counts.items())
+ ],
+ "counts_overlap": True,
}
@app.get("/api/posts/{post_id}")
async def read_post(
post_id: str,
+ request: Request,
as_of: str | None = None,
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""Return one source_post, or 404 / 403 if it is missing or out of scope.
- ``as_of`` adds ``known_at`` when a ``source_post_revision`` covers that
- clock (ADR 0025). The live ``post_body`` stays the live row. A missing
- cover is omitted -- never a fabricated cutoff sentence. Next action:
- pass the analysis-run cutoff, then compare ``known_at`` with the live
- body before treating the live text as reconstructed evidence.
+ ``as_of`` adds revision metadata when a ``source_post_revision`` covers
+ that clock (ADR 0025). Fetch the corresponding exact text from the body
+ endpoint after presenting this metadata; a missing historical cover is
+ never replaced with live text.
"""
_require_post_read(account)
+ if "include_body" in request.query_params:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_CONTENT,
+ "Fetch the complete source text from this Post's body endpoint.",
+ )
settings = load_settings()
as_of_clock = None
if as_of is not None:
@@ -1743,60 +2378,248 @@ async def read_post(
"then compare the known body with the live body.",
) from exc
async with pool.acquire() as conn:
- # Safe SQL: the eligibility predicate is an immutable schema fragment; post id is bound.
- row = await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
- "select post_id, post_title, post_body, voc_type_code, visibility_code, "
- "source_stage_code, source_detail_state_code, source_draft_code, source_deleted_flag, "
- "source_author_code, source_author_name, source_company_code, source_company_name, "
- "source_process_unit_code, source_process_unit_name, "
- "source_sales_pool_code, source_sales_pool_name, "
- "source_customer_code, source_customer_name, source_project_code, source_project_name, "
- "source_system_code, source_record_key, "
- "corporate_entity_id, process_unit_id, created_at "
- f"from source_post where post_id = $1 and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}",
+ evidence_configured = bool(
+ settings.orchestrator_base_url and settings.orchestrator_api_key
+ )
+ row = await _fetch_post_detail_bundle(
+ conn,
post_id,
+ as_of_clock,
+ account,
+ evidence_configured=evidence_configured,
)
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "post not found")
if not _can_see_post(account, row):
raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this post")
- labels = await _lookup_post_labels(conn, [row])
- project_evidence = await _load_project_evidence(
- conn, post_id, row["source_project_code"], row["source_project_name"]
- )
- voice_types = await _load_post_voice_types(conn, post_id, as_of_clock)
- if as_of_clock is None:
- occupational_construct_assertions = (
- await load_occupational_construct_assertions(conn, post_id)
+ def decoded_json(value: Any, fallback: Any) -> Any:
+ """Decode asyncpg's default JSON text codec without changing native values."""
+ return json.loads(value) if isinstance(value, str) else value or fallback
+
+ labels = decoded_json(row["labels"], {})
+ project_evidence = decoded_json(row["project_evidence"], [])
+ source_project_code = (row["source_project_code"] or "").strip()
+ source_project_name = (row["source_project_name"] or "").strip()
+ if source_project_code or source_project_name:
+ source_field = (
+ "source_post.source_project_name"
+ if source_project_name
+ else "source_post.source_project_code"
)
- occupational_construct_evidence_status = (
- await load_occupational_construct_evidence_status(
- conn,
- post_id,
- evidence_configured=bool(
- settings.orchestrator_base_url
- and settings.orchestrator_api_key
- ),
- )
- )
- else:
- occupational_construct_assertions = []
- occupational_construct_evidence_status = "historical_unavailable"
- known_at = None
- if as_of_clock is not None:
- known_at = await fetch_known_at_revision(conn, post_id, as_of_clock)
+ project_evidence.insert(0, {
+ "project_key": source_project_code or source_project_name,
+ "project_name": source_project_name or source_project_code,
+ "evidence": source_field,
+ "confidence": None,
+ "ontology_iri": str(LW.Project),
+ "ontology_label": "Project",
+ "extraction_method": "source_field_hint",
+ "resolution_status": "hint_only",
+ "provenance": source_field,
+ })
+ voice_types = decoded_json(row["voice_types"], [])
+ occupational_construct_assertions = decoded_json(
+ row["occupational_construct_assertions"], []
+ )
+ occupational_construct_evidence_status = row[
+ "occupational_construct_evidence_status"
+ ]
+ product_rows = decoded_json(row["product_evidence"], [])
+ product_analysis_state = decoded_json(row["product_analysis_state"], None)
+ known_at = decoded_json(row["known_at"], None)
payload = {
**_serialize_post(row, labels),
- "post_body": row["post_body"],
"project_evidence": project_evidence,
"voice_types": voice_types,
"occupational_construct_assertions": occupational_construct_assertions,
+ "product_evidence_status": (
+ {
+ "status_code": "historical_unavailable",
+ "next_action": "Review this post's product evidence separately from the historical body.",
+ }
+ if as_of_clock is not None
+ else
+ {
+ "status_code": "complete",
+ "next_action": (
+ "Open the linked products and source evidence."
+ if product_rows
+ else "Open the source text and confirm that no product was mentioned."
+ ),
+ }
+ if product_rows
+ or (product_analysis_state and product_analysis_state["analysis_present"])
+ else {
+ "status_code": (
+ "processing"
+ if product_analysis_state
+ and product_analysis_state["job_status_code"]
+ in {"post_content_ingestion_queued", "post_content_ingestion_running"}
+ else (
+ "setup_required"
+ if not (settings.orchestrator_base_url and settings.orchestrator_api_key)
+ else "unavailable"
+ )
+ ),
+ "next_action": (
+ "Review product evidence again after analysis finishes."
+ if product_analysis_state
+ and product_analysis_state["job_status_code"]
+ in {"post_content_ingestion_queued", "post_content_ingestion_running"}
+ else (
+ "Ask an administrator to enable product analysis, then review this post again."
+ if not (settings.orchestrator_base_url and settings.orchestrator_api_key)
+ else "Run product analysis again, then review the result."
+ )
+ ),
+ }
+ ),
+ "product_evidence": [
+ {
+ **item,
+ "ontology_iri": (
+ ontology_node_iri("product", str(item["product_catalog_id"]))
+ if item["product_catalog_id"] is not None
+ else None
+ ),
+ }
+ for item in product_rows
+ ],
}
+ if row["post_body"] is not None:
+ payload["post_body"] = row["post_body"]
if known_at is not None:
payload["known_at"] = known_at
return payload
+@app.get("/api/posts/{post_id}/body")
+async def stream_post_body(
+ post_id: str,
+ as_of: str | None = None,
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> StreamingResponse:
+ """Stream the complete authorized source body in bounded database slices."""
+ _require_post_read(account)
+ as_of_clock = None
+ if as_of is not None:
+ try:
+ as_of_clock = parse_as_of_clock(as_of)
+ except ValueError as exc:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_CONTENT,
+ "as_of must be an ISO-8601 timestamp. Use the run cutoff, then retry.",
+ ) from exc
+ revision_id: str | None = None
+ async with pool.acquire() as conn:
+ source_context_required = await has_real_source_context(
+ conn, list(account.corporate_entity_ids)
+ )
+ eligibility = source_post_eligibility_sql(
+ "source_post", source_context_required=source_context_required
+ )
+ # Safe SQL: eligibility is immutable schema text and the post id is bound.
+ visible_row = await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ "select source_post.post_id, visibility_code, corporate_entity_id, process_unit_id, "
+ "source_post.xmin::text as body_version, projection.post_body_character_count, "
+ "projection.post_body_byte_count from source_post "
+ "join post_list_read_projection projection on projection.post_id = source_post.post_id "
+ f"where source_post.post_id = $1 and {eligibility}",
+ post_id,
+ )
+ if as_of_clock is not None:
+ revision_value = await conn.fetchrow(
+ "select source_post_revision_id, xmin::text as body_version "
+ "from source_post_revision "
+ "where post_id = $1 and written_at <= $2 "
+ "and (superseded_at is null or superseded_at > $2) "
+ "order by written_at desc limit 1",
+ post_id,
+ as_of_clock,
+ )
+ revision_id = (
+ str(revision_value["source_post_revision_id"])
+ if revision_value is not None
+ else None
+ )
+ if visible_row is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "post not found")
+ if not _can_see_post(account, visible_row):
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "not authorized to view this post")
+ if as_of_clock is not None and revision_id is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "no source revision covers this cutoff")
+ body_version = str(
+ revision_value["body_version"]
+ if revision_id is not None
+ else visible_row["body_version"]
+ )
+ body_character_count = (
+ None
+ if revision_id is not None
+ else int(visible_row["post_body_character_count"])
+ )
+ body_byte_count = (
+ None if revision_id is not None else int(visible_row["post_body_byte_count"])
+ )
+
+ async def body_chunks() -> AsyncIterator[bytes]:
+ """Read complete Unicode text without materializing the full TOAST value."""
+ chunk_characters = 262_144
+ position = 1
+ while body_character_count is None or position <= body_character_count:
+ async with pool.acquire() as conn:
+ body_source = (
+ "source_post_revision revision"
+ if revision_id is not None
+ else "source_post revision"
+ )
+ body_key = (
+ "revision.source_post_revision_id = $1"
+ if revision_id is not None
+ else "revision.post_id = $1"
+ )
+ post_join = (
+ "join source_post post on post.post_id = revision.post_id"
+ if revision_id is not None
+ else "join source_post post on post.post_id = revision.post_id"
+ )
+ # Safe SQL: table/key fragments and eligibility are closed schema text.
+ chunk_row = await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ f"select substring(revision.post_body from $3::integer for $4::integer) as body_chunk "
+ f"from {body_source} {post_join} where {body_key} "
+ "and revision.xmin::text = $2 "
+ "and (post.visibility_code = 'public' or ("
+ "post.corporate_entity_id = any($5::uuid[]) and ("
+ "cardinality($6::uuid[]) = 0 or post.process_unit_id = any($6::uuid[])))) "
+ f"and {source_post_eligibility_sql('post', source_context_required=source_context_required)}",
+ revision_id or post_id,
+ body_version,
+ position,
+ chunk_characters,
+ list(account.corporate_entity_ids),
+ list(account.process_unit_ids),
+ )
+ if chunk_row is None:
+ raise RuntimeError("Post body changed while it was being transferred. Retry.")
+ chunk = str(chunk_row["body_chunk"] or "")
+ if not chunk:
+ break
+ yield str(chunk).encode("utf-8")
+ if len(chunk) < chunk_characters:
+ break
+ position += len(chunk)
+
+ response_headers = {"Cache-Control": "private, no-store"}
+ if body_byte_count is not None:
+ response_headers["Content-Length"] = str(body_byte_count)
+ return StreamingResponse(
+ body_chunks(),
+ media_type="text/plain; charset=utf-8",
+ headers=response_headers,
+ )
+
+
class CreatePostVoiceAssignmentRequest(BaseModel):
"""Evidence and governed truth state for one additional Voice assignment."""
@@ -2735,6 +3558,112 @@ async def verify_post_entity_relationships(
}
+@app.get("/api/posts/{post_id}/research-citations")
+async def read_post_research_citations(
+ post_id: str,
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, Any]:
+ """Return persisted public-research citations for this post's source leads."""
+
+ post = await _load_visible_post(post_id, account, pool)
+ if str(post["visibility_code"]) != VISIBILITY_PUBLIC:
+ return {
+ "post_id": str(post["post_id"]),
+ "visibility_code": post["visibility_code"],
+ "unavailable_reason": PRIVATE_POST_UNAVAILABLE,
+ "citations": [],
+ }
+ async with pool.acquire() as conn:
+ citations = await list_source_research_citations(conn, post_id)
+ return {
+ "post_id": str(post["post_id"]),
+ "visibility_code": post["visibility_code"],
+ "unavailable_reason": None,
+ "citations": [
+ {
+ "lead_kind_code": row["lead_kind_code"],
+ "lead_source_unit_id": row["lead_source_unit_id"],
+ "lead_image_region_id": row["lead_image_region_id"],
+ "lead_excerpt_text": row["lead_excerpt_text"],
+ "search_query_text": row["search_query_text"],
+ "evidence_url": row["evidence_url"],
+ "evidence_title_text": row["evidence_title_text"],
+ "evidence_excerpt_text": row["evidence_excerpt_text"],
+ "judgment_code": row["judgment_code"],
+ "rationale_text": row["rationale_text"],
+ "next_action_text": row["next_action_text"],
+ "checked_at": row["checked_at"],
+ }
+ for row in citations
+ ],
+ }
+
+
+@app.post("/api/posts/{post_id}/research-citations")
+async def research_post_source_references(
+ post_id: str,
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+ valkey: redis.Redis = Depends(get_valkey),
+) -> dict[str, Any]:
+ """Search and retrieve a public resource for this post's source leads.
+
+ Private posts fail closed without sending content. Gated by post_admin
+ because retrieval is a real external-search write action.
+ """
+
+ _require_post_admin(account)
+ post = await _load_visible_post(post_id, account, pool)
+ if str(post["visibility_code"]) != VISIBILITY_PUBLIC:
+ return {
+ "post_id": str(post["post_id"]),
+ "visibility_code": post["visibility_code"],
+ "unavailable_reason": PRIVATE_POST_UNAVAILABLE,
+ "citations": [],
+ }
+ client = _source_research_client()
+ if not client.available:
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE,
+ "Public research is unavailable. Ask an administrator to enable it, "
+ "then try again.",
+ )
+ try:
+ with use_llm_metadata(build_post_llm_metadata(post_id, post)):
+ run = await research_post_sources_from_pool(
+ pool,
+ client,
+ post_id,
+ visibility_code=str(post["visibility_code"]),
+ )
+ except (HttpClientError, OSError, ValueError) as exc:
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE,
+ "Public research could not be completed. Try again later or review "
+ "this post's existing evidence.",
+ ) from exc
+ except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed.
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE,
+ "Public research could not be completed. Try again later or review "
+ "this post's existing evidence.",
+ ) from exc
+ await publish_activity_event(
+ valkey,
+ post_id,
+ "source_research_checked",
+ account.user_account_id,
+ f"Public sources reviewed: {len(run.citations)} item(s)",
+ )
+ return {
+ "post_id": run.post_id,
+ "visibility_code": run.visibility_code,
+ "unavailable_reason": run.unavailable_reason,
+ "citations": [citation.to_payload() for citation in run.citations],
+ }
+
+
@app.post("/api/posts/{post_id}/extract-keymen")
async def extract_post_keymen(
post_id: str,
@@ -3999,10 +4928,11 @@ async def read_calendar(
@app.get("/api/rankings")
async def read_rankings(
+ request: Request,
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
- """RankWeave fusion of ABAC-visible posts (ADR 0024 / ADR 0167).
+ """List governed choices or fuse one exact selected context (ADR 0278).
Hidden posts are omitted from every channel. Never invents a fused
score or a theta. Channel evidence is computed from owned rank
@@ -4010,10 +4940,39 @@ async def read_rankings(
missing.
"""
_require_post_read(account)
+ expected = {"topic_model_run_id", "influence_run_id", "topic_index", "dimension", "context"}
+ supplied = set(request.query_params)
+ if supplied and supplied != expected:
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "select one complete topic context")
async with pool.acquire() as conn:
- posts = await load_visible_ranking_posts(
- conn, lambda row: _can_see_post(account, row)
+ if not supplied:
+ choices = await load_ranking_context_choices(
+ conn, account.corporate_entity_ids, account.process_unit_ids
+ )
+ return {"status": "selection_required", "context_choices": choices, "rankings": []}
+ try:
+ selection = {
+ "topic_model_run_id": str(UUID(request.query_params["topic_model_run_id"])),
+ "influence_run_id": str(UUID(request.query_params["influence_run_id"])),
+ "topic_index": int(request.query_params["topic_index"]),
+ "dimension": request.query_params["dimension"],
+ "context": request.query_params["context"],
+ }
+ except ValueError as exc:
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "ranking selection is invalid") from exc
+ if (
+ selection["topic_index"] < 0
+ or selection["dimension"] not in {"business_unit", "process_unit", "team", "person"}
+ or not selection["context"].strip()
+ ):
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "ranking selection is invalid")
+ rows = await load_selected_ranking_rows(
+ conn, selection, account.corporate_entity_ids, account.process_unit_ids
)
- return _rankweave_client().as_api_payload(
- posts, can_see_post=lambda _row: True
- )
+ if not rows:
+ return {"status": "unavailable", "status_reason": "selected_evidence_not_available", "selection": selection, "rankings": []}
+ try:
+ rankings = _rankweave_client().fuse_selected_rows(rows)
+ except RankWeaveNotAvailable:
+ return {"status": "unavailable", "status_reason": RankWeaveNotAvailable.reason, "selection": selection, "rankings": []}
+ return {"status": "accepted", "status_reason": None, "selection": selection, "rankings": rankings.to_json()}
diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py
index 21b1adfbf..751fc3c40 100644
--- a/backend/app/mcp_server.py
+++ b/backend/app/mcp_server.py
@@ -250,7 +250,7 @@ async def lifespan(_: MCPServer) -> AsyncIterator[McpAppContext]:
"lineageweave",
title="LineageWeave",
description="Authenticated provenance-bearing lineage intelligence.",
- version="2.18.0",
+ version="2.19.0",
lifespan=lifespan,
token_verifier=token_verifier or KeyverseMcpTokenVerifier(resolved),
auth=AuthSettings(
diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py
index a2a1fc84f..86092296d 100644
--- a/backend/app/operations_case_ingestion.py
+++ b/backend/app/operations_case_ingestion.py
@@ -17,6 +17,10 @@ async def execute(self, query: str, *args: object) -> Any:
"""Execute one parameterized statement."""
pass
+ async def fetchval(self, query: str, *args: object) -> Any:
+ """Fetch one scalar value."""
+ pass
+
async def executemany(self, query: str, args: list[tuple[object, ...]]) -> Any:
"""Execute one parameterized statement for several rows."""
pass
@@ -33,15 +37,32 @@ async def persist_operations_cases(
source_body: str,
orchestrator_session_id: str,
cases: tuple[OperationsCase, ...],
+ *,
+ analysis_input_sha256: str,
) -> None:
"""Atomically replace one post's normalized case analysis."""
async with conn.transaction():
- await conn.execute("delete from operations_case_analysis where post_id = $1", post_id)
+ current_digest = await conn.fetchval(
+ "select encode(sha256(convert_to(coalesce(post_body, ''), 'UTF8')), 'hex') "
+ "from source_post where post_id = $1::uuid for update",
+ post_id,
+ )
+ if current_digest != source_body_sha256(source_body):
+ raise ValueError("operations result no longer matches the source revision")
+ # Product-to-fact evidence is valid only for the exact normalized
+ # target rows it was extracted against. Removing the owning analysis
+ # under the shared source-row lock prevents concurrent product writes
+ # from publishing a completion for the target set being replaced.
+ await conn.execute("delete from post_product_analysis where post_id = $1", post_id)
await conn.execute(
- "insert into operations_case_analysis (post_id, source_body_sha256, orchestrator_session_id) values ($1, $2, $3)",
+ "delete from operations_case_analysis where post_id = $1", post_id
+ )
+ await conn.execute(
+ "insert into operations_case_analysis (post_id, source_body_sha256, orchestrator_session_id, analysis_input_sha256) values ($1, $2, $3, $4)",
post_id,
source_body_sha256(source_body),
orchestrator_session_id,
+ analysis_input_sha256,
)
for case in cases:
await conn.execute(
@@ -55,9 +76,42 @@ async def persist_operations_cases(
)
if case.facts:
await conn.executemany(
- "insert into operations_case_fact (post_id, case_kind_code, fact_ordinal, fact_type_code, value_text, evidence_text, evidence_post_id, evidence_input_sha256) values ($1, $2, $3, $4, $5, $6, $7, $8)",
+ "insert into operations_case_fact (post_id, case_kind_code, fact_ordinal, fact_type_code, value_text, evidence_text, evidence_post_id, evidence_input_sha256, relation_target_kind_code) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
- (post_id, case.case_kind_code, ordinal, fact.fact_type_code, fact.value_text, fact.evidence_text, fact.evidence_post_id, fact.evidence_input_sha256)
+ (post_id, case.case_kind_code, ordinal, fact.fact_type_code, fact.value_text, fact.evidence_text, fact.evidence_post_id, fact.evidence_input_sha256, fact.relation_target_kind_code)
for ordinal, fact in enumerate(case.facts)
],
)
+ if case.missing_fact_type_codes:
+ await conn.executemany(
+ "insert into operations_case_missing_fact (post_id, case_kind_code, fact_type_code) values ($1, $2, $3)",
+ [
+ (post_id, case.case_kind_code, code)
+ for code in case.missing_fact_type_codes
+ ],
+ )
+ if case.milestones:
+ await conn.executemany(
+ "insert into operations_case_milestone (post_id, case_kind_code, milestone_type_code, evidence_text, evidence_post_id, evidence_input_sha256, observed_at, time_axis_code) values ($1, $2, $3, $4, $5, $6, $7, $8)",
+ [
+ (
+ post_id,
+ case.case_kind_code,
+ milestone.milestone_type_code,
+ milestone.evidence_text,
+ milestone.evidence_post_id,
+ milestone.evidence_input_sha256,
+ milestone.observed_at,
+ milestone.time_axis_code,
+ )
+ for milestone in case.milestones
+ ],
+ )
+ if case.missing_milestone_type_codes:
+ await conn.executemany(
+ "insert into operations_case_missing_milestone (post_id, case_kind_code, milestone_type_code) values ($1, $2, $3)",
+ [
+ (post_id, case.case_kind_code, code)
+ for code in case.missing_milestone_type_codes
+ ],
+ )
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index 6342d8fd4..1cda3b69f 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -2,10 +2,17 @@
from __future__ import annotations
-from datetime import date
+import base64
+from datetime import date, datetime, time
+import json
from typing import Any, Protocol
+from uuid import UUID
+from zoneinfo import ZoneInfo
-from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
+from backend.app.post_eligibility import source_post_eligibility_sql
+from lineageweave.ontology import LW
+from lineageweave.operations_case_analysis import REQUIRED_FACT_TYPES
+from lineageweave.prov_o import PROV_RELATIONS
CASE_KIND_LABELS = {
@@ -27,26 +34,212 @@
"issue_pattern": "반복 유형",
"improvement_action": "개선 조치",
}
+MILESTONE_TYPE_LABELS = {
+ "claim_received": "클레임 접수",
+ "cause_confirmed": "원인 확정",
+ "rebid_response_requested": "재입찰 대응 요청",
+ "rebid_decision_recorded": "재입찰 의사결정",
+ "handover_started": "인수인계 시작",
+ "handover_accepted": "인수 확인",
+}
+LIFECYCLE_DEFINITIONS = (
+ ("claim_investigation", "claim_investigation", "클레임 원인 규명", "claim_received", "cause_confirmed"),
+ ("rebid_response", "rebid_handover", "재입찰 대응", "rebid_response_requested", "rebid_decision_recorded"),
+ ("handover_gap", "rebid_handover", "인수인계 공백", "handover_started", "handover_accepted"),
+)
+CASE_KIND_ONTOLOGY_CLASSES = {
+ "claim_investigation": str(LW.ClaimInvestigation),
+ "rebid_handover": str(LW.RebidHandover),
+ "external_information": str(LW.ExternalInformation),
+ "repeat_issue": str(LW.RepeatIssue),
+}
+EXTERNAL_RELATION_TARGETS = {
+ "order": ("수주", str(LW.Order), str(LW.relatesToOrder)),
+ "project": ("프로젝트", str(LW.Project), str(LW.relatesToProject)),
+ "sales": ("영업", str(LW.SalesContext), str(LW.relatesToSales)),
+ "business_management": (
+ "사업 관리",
+ str(LW.BusinessManagementContext),
+ str(LW.relatesToBusinessManagement),
+ ),
+}
+PROV_WAS_DERIVED_FROM = PROV_RELATIONS["wasDerivedFrom"].iri
+DASHBOARD_CASE_PAGE_SIZE = 20
+DASHBOARD_CASE_PAGE_SIZE_MAX = 50
+
+
+def _decode_case_cursor(raw: str | None) -> tuple[datetime, str, str] | None:
+ """Decode and validate the last key of a Dashboard case page."""
+ if not raw:
+ return None
+ try:
+ padding = "=" * (-len(raw) % 4)
+ payload = json.loads(base64.urlsafe_b64decode(raw + padding))
+ occurred_at = datetime.fromisoformat(payload["occurred_at"])
+ post_id = str(UUID(payload["post_id"]))
+ case_kind_code = payload["case_kind_code"]
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
+ raise ValueError("Resume from the last Dashboard case returned.") from exc
+ if occurred_at.tzinfo is None or case_kind_code not in CASE_KIND_LABELS:
+ raise ValueError("Resume from the last Dashboard case returned.")
+ return occurred_at, post_id, case_kind_code
+
+
+def _encode_case_cursor(row: Any) -> str:
+ """Encode the stable sort key of one Dashboard case page."""
+ payload = json.dumps(
+ {
+ "occurred_at": row["occurred_at"].isoformat(),
+ "post_id": str(row["post_id"]),
+ "case_kind_code": row["case_kind_code"],
+ },
+ separators=(",", ":"),
+ sort_keys=True,
+ ).encode("utf-8")
+ return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
+
+
+def _operations_case_jsonld(
+ post_id: str,
+ case_kind_code: str,
+ evidence_post_id: str,
+ case_facts: list[dict[str, str]],
+) -> dict[str, Any]:
+ """Project one persisted case and its cited facts as bounded JSON-LD."""
+ case_id = f"urn:lineageweave:operations-case:{post_id}:{case_kind_code}"
+ statements: list[dict[str, Any]] = []
+ for ordinal, fact in enumerate(case_facts):
+ statement: dict[str, Any] = {
+ "@id": f"{case_id}:fact:{ordinal}",
+ "@type": [str(LW.OperationsCaseFact), "http://www.w3.org/ns/prov#Entity"],
+ str(LW.factTypeCode): fact["fact_type_code"],
+ str(LW.factValue): fact["value_text"],
+ PROV_WAS_DERIVED_FROM: {
+ "@id": f"urn:lineageweave:post:{fact['evidence_post_id']}",
+ "@type": [str(LW.Post), "http://www.w3.org/ns/prov#Entity"],
+ },
+ }
+ predicate = fact.get("relation_predicate_iri")
+ target_class = fact.get("relation_target_class_iri")
+ if predicate and target_class:
+ statement.update(
+ {
+ "http://www.w3.org/1999/02/22-rdf-syntax-ns#subject": {
+ "@id": case_id
+ },
+ "http://www.w3.org/1999/02/22-rdf-syntax-ns#predicate": {
+ "@id": predicate
+ },
+ "http://www.w3.org/1999/02/22-rdf-syntax-ns#object": {
+ "@id": f"{case_id}:fact:{ordinal}:target",
+ "@type": target_class,
+ "http://www.w3.org/2000/01/rdf-schema#label": fact["value_text"],
+ },
+ }
+ )
+ statements.append(statement)
+ return {
+ "@context": {
+ "lw": str(LW),
+ "prov": "http://www.w3.org/ns/prov#",
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
+ },
+ "@id": case_id,
+ "@type": [CASE_KIND_ONTOLOGY_CLASSES[case_kind_code], "prov:Entity"],
+ "prov:wasDerivedFrom": {
+ "@id": f"urn:lineageweave:post:{evidence_post_id}",
+ "@type": [str(LW.Post), "prov:Entity"],
+ },
+ str(LW.hasOperationsFact): statements,
+ }
class _Connection(Protocol):
async def fetchrow(self, query: str, *args: object) -> Any:
"""Fetch one projected row."""
- pass
+ pass # pragma: no cover - structural Protocol member
async def fetch(self, query: str, *args: object) -> list[Any]:
"""Fetch projected rows."""
- pass
+ pass # pragma: no cover - structural Protocol member
+
+
+def _json_array(value: Any) -> list[dict[str, Any]]:
+ """Normalize an asyncpg JSON array without changing its values."""
+ decoded = json.loads(value) if isinstance(value, str) else value
+ return [dict(row) for row in (decoded or [])]
+
+
+class _DashboardBundleConnection:
+ """Expose one database JSON bundle to the existing pure projection path."""
+
+ def __init__(self, bundle: Any) -> None:
+ self.metrics = dict(
+ json.loads(bundle["metrics"])
+ if isinstance(bundle["metrics"], str)
+ else bundle["metrics"]
+ )
+ self.case_rollups = _json_array(bundle["case_rollups"])
+ self.cases = _json_array(bundle["cases"])
+ for row in self.cases:
+ if isinstance(row["occurred_at"], str):
+ row["occurred_at"] = datetime.fromisoformat(row["occurred_at"])
+ self.details = _json_array(bundle["details"])
+ self.topic_readiness = dict(
+ json.loads(bundle["topic_readiness"])
+ if isinstance(bundle["topic_readiness"], str)
+ else bundle["topic_readiness"]
+ )
+ self.topic_readiness = {
+ "tepp_posterior_persisted": self.topic_readiness.get("topic_tepp_ready"),
+ "fast_mlsirm_influence_persisted": self.topic_readiness.get("topic_fast_ready"),
+ }
+ self.topic_details = _json_array(bundle["topic_details"])
+ for row in self.topic_details:
+ for key in (
+ "occurred_at", "activity_valid_from", "activity_valid_to",
+ "knowledge_cutoff", "accepted_at",
+ ):
+ if isinstance(row.get(key), str):
+ row[key] = datetime.fromisoformat(row[key])
+
+ async def fetchrow(self, query: str, *args: object) -> Any:
+ """Return the bundled scalar section selected by the projection."""
+ if "tepp_posterior_persisted" in query:
+ return self.topic_readiness
+ return self.metrics
+ async def fetch(self, query: str, *args: object) -> list[Any]:
+ """Return the bundled row section selected by the projection."""
+ if "dashboard_case_rollup" in query:
+ return self.case_rollups
+ if "select row_kind, payload" in query:
+ return self.details
+ if "from topic_post_context_influence influence" in query:
+ return self.topic_details
+ if "limit $9" in query:
+ return self.cases
+ return []
-def _visible_period_sql(alias: str = "post") -> str:
- """Return the shared ABAC, eligibility, and event-clock predicate."""
+def _visible_scope_sql(
+ alias: str = "post", *, source_context_required: bool | None = None
+) -> str:
+ """Return the shared ABAC and source-eligibility predicate."""
return f"""
({alias}.visibility_code = 'public'
or ({alias}.corporate_entity_id::text = any($1::text[])
and (cardinality($2::text[]) = 0
or {alias}.process_unit_id::text = any($2::text[]))))
- and {SOURCE_POST_ELIGIBILITY_SQL.format(alias=alias)}
+ and {source_post_eligibility_sql(alias, source_context_required=source_context_required)}
+ """
+
+
+def _visible_period_sql(
+ alias: str = "post", *, source_context_required: bool | None = None
+) -> str:
+ """Return the shared visibility predicate plus the requested event interval."""
+ return f"""
+ {_visible_scope_sql(alias, source_context_required=source_context_required)}
and ($3::date is null or (coalesce({alias}.event_occurred_at, {alias}.created_at)
at time zone 'Asia/Seoul')::date >= $3)
and ($4::date is null or (coalesce({alias}.event_occurred_at, {alias}.created_at)
@@ -54,48 +247,618 @@ def _visible_period_sql(alias: str = "post") -> str:
"""
+def _visible_projection_scope_sql(
+ alias: str, *, source_context_required: bool | None
+) -> str:
+ """Return ABAC and eligibility over the maintained narrow read projection."""
+ context = (
+ ""
+ if source_context_required is False
+ else f"and {alias}.source_context_present"
+ if source_context_required is True
+ else f"""and ({alias}.source_context_present or not exists (
+ select 1 from dashboard_post_read_projection real_post
+ where real_post.active_source
+ and real_post.source_context_present
+ and (real_post.visibility_code = 'public'
+ or (real_post.corporate_entity_id = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or real_post.process_unit_id = any($2::uuid[]))))
+ ))"""
+ )
+ return f"""
+ ({alias}.visibility_code = 'public'
+ or ({alias}.corporate_entity_id = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or {alias}.process_unit_id = any($2::uuid[]))))
+ and {alias}.active_source {context}
+ """
+
+
+def _visible_projection_period_sql(
+ alias: str, *, source_context_required: bool | None
+) -> str:
+ """Return projected ABAC and the requested event-date interval."""
+ return f"""
+ {_visible_projection_scope_sql(alias, source_context_required=source_context_required)}
+ and ($3::date is null or {alias}.occurred_date >= $3)
+ and ($4::date is null or {alias}.occurred_date <= $4)
+ """
+
+
+def _project_identity_lateral_sql(
+ post_alias: str, post_id_column: str = "source_post_id"
+) -> str:
+ """Return exact source/semantic project keys separately from display names."""
+ return f"""
+ left join lateral (
+ select array_agg(identity.project_key order by identity.project_name,
+ identity.project_key) as project_keys,
+ array_agg(identity.project_name order by identity.project_name,
+ identity.project_key) as project_key_labels,
+ array_agg(identity.key_provenance order by identity.project_name,
+ identity.project_key)
+ as project_key_provenances
+ from (
+ select distinct on (
+ lower(btrim(normalize(candidate.project_key, NFKC),
+ E' \t\n\r\f\v'))
+ )
+ candidate.project_key,
+ candidate.project_name,
+ candidate.key_provenance
+ from (
+ select nullif(btrim({post_alias}.source_project_code), '')
+ as project_key,
+ coalesce(nullif(btrim({post_alias}.source_project_name), ''),
+ nullif(btrim({post_alias}.source_project_code), ''))
+ as project_name,
+ 'source_post.source_project_code'::text as key_provenance,
+ 0 as provenance_priority
+ union all
+ select nullif(btrim(key_mention.project_key), ''),
+ coalesce(nullif(btrim(key_mention.project_name), ''),
+ nullif(btrim(key_mention.project_key), '')),
+ 'post_project_mention.project_key'::text,
+ 1
+ from post_project_mention key_mention
+ where key_mention.post_id = {post_alias}.{post_id_column}
+ ) candidate
+ where candidate.project_key is not null
+ order by lower(btrim(normalize(candidate.project_key, NFKC),
+ E' \t\n\r\f\v')),
+ candidate.provenance_priority,
+ candidate.project_name,
+ candidate.project_key
+ ) identity
+ ) project_identity on true
+ """
+
+
+def _dashboard_single_statement_sql(source_context_required: bool | None) -> str:
+ """Return the exact Dashboard read contract as one PostgreSQL statement."""
+ visible = _visible_projection_period_sql(
+ "post", source_context_required=source_context_required
+ )
+ evidence = _visible_projection_scope_sql(
+ "evidence_post", source_context_required=source_context_required
+ )
+ milestone_evidence = _visible_projection_scope_sql(
+ "milestone_evidence", source_context_required=source_context_required
+ )
+ contributor_evidence = _visible_projection_scope_sql(
+ "contributor_evidence", source_context_required=source_context_required
+ )
+ summary_context = (
+ "summary.source_context_present"
+ if source_context_required is True
+ else "true"
+ if source_context_required is False
+ else """(summary.source_context_present or not exists (
+ select 1 from dashboard_post_read_projection real_post
+ where real_post.active_source
+ and real_post.source_context_present
+ and (real_post.visibility_code = 'public'
+ or (real_post.corporate_entity_id = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or real_post.process_unit_id = any($2::uuid[]))))
+ ))"""
+ )
+ return f"""
+ /* dashboard_single_statement */
+ with visible_post as not materialized (
+ select post.*
+ from dashboard_post_read_projection post
+ where {visible}
+ ), external_post as materialized (
+ select classification.post_id,
+ bool_or(not visible_post.case_analysis_present
+ and not visible_post.ingestion_failed) as pending_analysis,
+ bool_or(visible_post.ingestion_failed) as failed_analysis
+ from operations_case_classification classification
+ join visible_post on visible_post.source_post_id = classification.post_id
+ join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = classification.evidence_post_id
+ where {evidence}
+ and classification.case_kind_code = 'external_information'
+ group by classification.post_id
+ ), summary_metric as (
+ select coalesce(sum(summary.total_post_count), 0) as total_post_count,
+ coalesce(sum(summary.pending_analysis_count), 0)
+ as pending_analysis_count,
+ coalesce(sum(summary.failed_analysis_count), 0)
+ as failed_analysis_count
+ from dashboard_post_daily_summary summary
+ where (summary.visibility_code = 'public'
+ or (summary.corporate_entity_id = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or summary.process_unit_id = any($2::uuid[]))))
+ and ($3::date is null or summary.occurred_date >= $3)
+ and ($4::date is null or summary.occurred_date <= $4)
+ and ({summary_context})
+ ), metric as (
+ select summary_metric.total_post_count,
+ (select count(*) from external_post) as external_post_count,
+ case when $5::boolean
+ then (select count(*) from external_post where pending_analysis)
+ else summary_metric.pending_analysis_count end
+ as pending_analysis_count,
+ case when $5::boolean
+ then (select count(*) from external_post where failed_analysis)
+ else summary_metric.failed_analysis_count end
+ as failed_analysis_count
+ from summary_metric
+ ), case_rollup as materialized (
+ select rollup.source_post_id as post_id, rollup.case_kind_code,
+ coalesce(milestone.event_count, 0) as event_count,
+ coalesce(milestone.claim_started, false) as claim_started,
+ coalesce(milestone.claim_ended, false) as claim_ended,
+ coalesce(milestone.rebid_started, false) as rebid_started,
+ coalesce(milestone.rebid_ended, false) as rebid_ended,
+ coalesce(milestone.handover_started, false) as handover_started,
+ coalesce(milestone.handover_ended, false) as handover_ended,
+ rollup.claim_start_missing, rollup.rebid_start_missing,
+ rollup.handover_start_missing
+ from dashboard_case_rollup_read_projection rollup
+ join visible_post post on post.source_post_id = rollup.source_post_id
+ join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = rollup.classification_evidence_post_id
+ left join lateral (
+ select sum(value.event_count) as event_count,
+ bool_or(value.claim_started) as claim_started,
+ bool_or(value.claim_ended) as claim_ended,
+ bool_or(value.rebid_started) as rebid_started,
+ bool_or(value.rebid_ended) as rebid_ended,
+ bool_or(value.handover_started) as handover_started,
+ bool_or(value.handover_ended) as handover_ended
+ from dashboard_case_milestone_read_projection value
+ join dashboard_post_read_projection milestone_evidence
+ on milestone_evidence.source_post_id = value.evidence_post_id
+ where value.source_post_id = rollup.source_post_id
+ and value.case_kind_code = rollup.case_kind_code
+ and {milestone_evidence}
+ ) milestone on true
+ where {evidence}
+ and not exists (
+ select 1
+ from dashboard_case_contributor_read_projection contributor
+ left join dashboard_post_read_projection contributor_evidence
+ on contributor_evidence.source_post_id = contributor.evidence_post_id
+ where contributor.source_post_id = rollup.source_post_id
+ and contributor.case_kind_code = rollup.case_kind_code
+ and (contributor_evidence.source_post_id is null
+ or not ({contributor_evidence}))
+ )
+ and ($5::boolean is false or rollup.case_kind_code = 'external_information')
+ ), case_ranked as materialized (
+ select rollup.source_post_id as post_id, rollup.case_kind_code,
+ rollup.summary_text, rollup.evidence_text,
+ rollup.classification_evidence_post_id as evidence_post_id,
+ rollup.occurred_at, rollup.project_name, rollup.project_names,
+ coalesce(project_identity.project_keys, array[]::text[]) as project_keys,
+ coalesce(project_identity.project_key_labels, array[]::text[])
+ as project_key_labels,
+ coalesce(project_identity.project_key_provenances, array[]::text[])
+ as project_key_provenances
+ from dashboard_case_rollup_read_projection rollup
+ join visible_post post on post.source_post_id = rollup.source_post_id
+ join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = rollup.classification_evidence_post_id
+ {_project_identity_lateral_sql('post')}
+ where {evidence}
+ and not exists (
+ select 1
+ from dashboard_case_contributor_read_projection contributor
+ left join dashboard_post_read_projection contributor_evidence
+ on contributor_evidence.source_post_id = contributor.evidence_post_id
+ where contributor.source_post_id = rollup.source_post_id
+ and contributor.case_kind_code = rollup.case_kind_code
+ and (contributor_evidence.source_post_id is null
+ or not ({contributor_evidence}))
+ )
+ and ($5::boolean is false or rollup.case_kind_code = 'external_information')
+ and ($6::timestamptz is null
+ or (rollup.occurred_at,
+ rollup.source_post_id, rollup.case_kind_code)
+ < ($6, $7::uuid, $8::text))
+ order by rollup.occurred_at desc, rollup.source_post_id desc,
+ rollup.case_kind_code desc
+ limit $9 + 1
+ ), case_summary as materialized (
+ select case_kind_code,
+ count(*) as post_count,
+ coalesce(sum(event_count), 0) as event_count,
+ count(*) filter (where claim_started and claim_ended
+ and not claim_start_missing) as claim_resolved,
+ count(*) filter (where claim_started and not claim_ended
+ and not claim_start_missing) as claim_open,
+ count(*) filter (where not claim_started or claim_start_missing)
+ as claim_missing,
+ count(*) filter (where rebid_started and rebid_ended
+ and not rebid_start_missing) as rebid_resolved,
+ count(*) filter (where rebid_started and not rebid_ended
+ and not rebid_start_missing) as rebid_open,
+ count(*) filter (where not rebid_started or rebid_start_missing)
+ as rebid_missing,
+ count(*) filter (where handover_started and handover_ended
+ and not handover_start_missing) as handover_resolved,
+ count(*) filter (where handover_started and not handover_ended
+ and not handover_start_missing) as handover_open,
+ count(*) filter (where not handover_started or handover_start_missing)
+ as handover_missing
+ from case_rollup
+ group by case_kind_code
+ ), selected_case as materialized (
+ select post_id, case_kind_code
+ from case_ranked
+ order by occurred_at desc, post_id desc, case_kind_code desc
+ limit $9
+ ), detail as materialized (
+ select 'fact'::text as row_kind, fact.post_id, fact.case_kind_code,
+ fact.fact_ordinal::bigint as sort_ordinal,
+ jsonb_build_object(
+ 'post_id', fact.post_id, 'case_kind_code', fact.case_kind_code,
+ 'fact_type_code', fact.fact_type_code, 'value_text', fact.value_text,
+ 'evidence_text', fact.evidence_text,
+ 'evidence_post_id', fact.evidence_post_id,
+ 'fact_ordinal', fact.fact_ordinal,
+ 'relation_target_kind_code', fact.relation_target_kind_code
+ ) as payload
+ from selected_case selected
+ join operations_case_fact fact using (post_id, case_kind_code)
+ join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = fact.evidence_post_id
+ where {evidence}
+ union all
+ select 'product', relation.post_id, relation.case_kind_code,
+ relation.fact_ordinal::bigint,
+ jsonb_build_object(
+ 'post_id', relation.post_id, 'case_kind_code', relation.case_kind_code,
+ 'fact_ordinal', relation.fact_ordinal,
+ 'relation_type_code', relation.relation_type_code,
+ 'extracted_product_name', mention.extracted_product_name,
+ 'canonical_product_name', catalog.canonical_product_name,
+ 'evidence_text', relation.evidence_text,
+ 'evidence_post_id', relation.evidence_post_id
+ )
+ from selected_case selected
+ join product_operations_fact_relation relation using (post_id, case_kind_code)
+ join post_product_analysis product_analysis
+ on product_analysis.post_id = relation.post_id
+ and product_analysis.orchestrator_model_receipt is not null
+ join post_content_ingestion_job product_job
+ on product_job.post_id = relation.post_id
+ and product_job.source_body_sha256 = product_analysis.source_body_sha256
+ join post_product_mention mention
+ on mention.post_id = relation.post_id
+ and mention.mention_ordinal = relation.mention_ordinal
+ left join product_catalog catalog
+ on catalog.product_catalog_id = mention.product_catalog_id
+ join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = relation.evidence_post_id
+ where {evidence}
+ union all
+ select 'missing_fact', missing.post_id, missing.case_kind_code, 0,
+ jsonb_build_object(
+ 'post_id', missing.post_id, 'case_kind_code', missing.case_kind_code,
+ 'fact_type_code', missing.fact_type_code
+ )
+ from selected_case selected
+ join operations_case_missing_fact missing using (post_id, case_kind_code)
+ union all
+ select 'missing_fact', fact.post_id, fact.case_kind_code,
+ fact.fact_ordinal::bigint,
+ jsonb_build_object(
+ 'post_id', fact.post_id, 'case_kind_code', fact.case_kind_code,
+ 'fact_type_code', fact.fact_type_code
+ )
+ from selected_case selected
+ join operations_case_fact fact using (post_id, case_kind_code)
+ left join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = fact.evidence_post_id
+ where (evidence_post.source_post_id is null or not ({evidence}))
+ and ($10::jsonb -> fact.case_kind_code) ? fact.fact_type_code
+ union all
+ select 'milestone', milestone.post_id, milestone.case_kind_code,
+ (extract(epoch from milestone.observed_at) * 1000000)::bigint,
+ jsonb_build_object(
+ 'post_id', milestone.post_id,
+ 'case_kind_code', milestone.case_kind_code,
+ 'milestone_type_code', milestone.milestone_type_code,
+ 'evidence_text', milestone.evidence_text,
+ 'evidence_post_id', milestone.evidence_post_id,
+ 'observed_at', milestone.observed_at,
+ 'time_axis_code', milestone.time_axis_code,
+ 'is_missing', false
+ )
+ from selected_case selected
+ join operations_case_milestone milestone using (post_id, case_kind_code)
+ join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = milestone.evidence_post_id
+ where {evidence}
+ union all
+ select 'milestone', missing.post_id, missing.case_kind_code,
+ 9223372036854775807,
+ jsonb_build_object(
+ 'post_id', missing.post_id,
+ 'case_kind_code', missing.case_kind_code,
+ 'milestone_type_code', missing.milestone_type_code,
+ 'evidence_text', null, 'evidence_post_id', null,
+ 'observed_at', null, 'time_axis_code', null,
+ 'is_missing', true
+ )
+ from selected_case selected
+ join operations_case_missing_milestone missing using (post_id, case_kind_code)
+ ), topic_candidate as materialized (
+ select model.topic_model_run_id, influence_run.topic_influence_run_id,
+ model.tepp_run_id, model.tepp_snapshot_id,
+ model.tepp_schema_version, model.tepp_model_contract_version,
+ model.tepp_artifact_sha256, model.posterior_draw_set_id,
+ model.posterior_draw_count, model.topic_count,
+ snapshot.snapshot_sha256 as source_snapshot_sha256,
+ analysis.knowledge_cutoff,
+ influence_run.fast_mlsirm_schema_version,
+ influence_run.fast_mlsirm_version,
+ influence_run.fast_mlsirm_code_revision,
+ influence_run.fast_mlsirm_artifact_sha256,
+ influence_run.compute_backend_code, influence_run.precision_code,
+ influence_run.membership_fingerprint_sha256
+ from topic_model_run model
+ join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id
+ join analysis_source_snapshot snapshot
+ on snapshot.analysis_source_snapshot_id = analysis.analysis_source_snapshot_id
+ join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id
+ join topic_influence_run influence_run
+ on influence_run.topic_model_run_id = model.topic_model_run_id
+ where $5::boolean is false
+ and ((scope.scope_kind_code = 'analysis_scope_corporate_entity'
+ and scope.corporate_entity_id = any($1::uuid[])
+ and cardinality($2::uuid[]) = 0)
+ or (scope.scope_kind_code = 'analysis_scope_process_unit'
+ and scope.process_unit_id = any($2::uuid[])))
+ order by influence_run.accepted_at desc, model.topic_model_run_id,
+ influence_run.topic_influence_run_id
+ limit 1
+ ), topic_detail as materialized (
+ select influence.*, membership.dimension_code, membership.context_id,
+ context.context_label, membership.membership_weight,
+ membership.source_post_id,
+ evidence_binding.node_id as membership_evidence_post_id,
+ post.occurred_at,
+ activity.state_code, activity.valid_from as activity_valid_from,
+ activity.valid_to as activity_valid_to,
+ candidate.*,
+ evidence_post.source_post_id is not null
+ and not exists (
+ select 1
+ from topic_lineage_relation checked_relation
+ left join provenance_assertion checked_assertion
+ on checked_assertion.assertion_id = checked_relation.provenance_assertion_id
+ left join provenance_resource_binding checked_binding
+ on checked_binding.resource_id = checked_assertion.object_resource_id
+ and checked_binding.node_type_code = 'node_post'
+ left join visible_post checked_visible
+ on checked_visible.source_post_id = checked_binding.node_id
+ where checked_relation.topic_model_run_id = candidate.topic_model_run_id
+ and checked_visible.source_post_id is null
+ ) as provenance_complete,
+ coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'event_code', relation.event_code,
+ 'source_topic_index', relation.source_topic_index,
+ 'target_topic_index', relation.target_topic_index,
+ 'event_time', relation.event_time,
+ 'evidence_post_id', relation_binding.node_id
+ ) order by relation.event_time, relation.relation_ordinal)
+ from topic_lineage_relation relation
+ join provenance_assertion relation_assertion
+ on relation_assertion.assertion_id = relation.provenance_assertion_id
+ join provenance_resource_binding relation_binding
+ on relation_binding.resource_id = relation_assertion.object_resource_id
+ and relation_binding.node_type_code = 'node_post'
+ join visible_post relation_visible
+ on relation_visible.source_post_id = relation_binding.node_id
+ where relation.topic_model_run_id = candidate.topic_model_run_id
+ and (relation.source_topic_index = influence.topic_index
+ or relation.target_topic_index = influence.topic_index)
+ ), '[]'::jsonb) as lineage_events
+ from topic_candidate candidate
+ join topic_post_context_influence influence
+ on influence.topic_model_run_id = candidate.topic_model_run_id
+ and influence.topic_influence_run_id = candidate.topic_influence_run_id
+ join topic_context_membership membership
+ on membership.topic_model_run_id = influence.topic_model_run_id
+ and membership.topic_context_membership_id = influence.topic_context_membership_id
+ join topic_context_definition context
+ on context.topic_model_run_id = membership.topic_model_run_id
+ and context.dimension_code = membership.dimension_code
+ and context.context_id = membership.context_id
+ join visible_post post on post.source_post_id = membership.source_post_id
+ join topic_activity_interval activity
+ on activity.topic_model_run_id = influence.topic_model_run_id
+ and activity.topic_index = influence.topic_index
+ and post.occurred_at >= activity.valid_from
+ and post.occurred_at < activity.valid_to
+ and post.occurred_at >= membership.valid_from
+ and post.occurred_at < membership.valid_to
+ left join provenance_assertion membership_assertion
+ on membership_assertion.assertion_id = membership.provenance_assertion_id
+ left join provenance_resource_binding evidence_binding
+ on evidence_binding.resource_id = membership_assertion.object_resource_id
+ and evidence_binding.node_type_code = 'node_post'
+ left join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = evidence_binding.node_id
+ and {evidence}
+ )
+ select row_to_json(metric) as metrics,
+ coalesce((select json_agg(row_to_json(case_summary)
+ order by case_kind_code)
+ from case_summary), '[]'::json) as case_rollups,
+ coalesce((select json_agg(row_to_json(case_ranked)
+ order by occurred_at desc, post_id desc,
+ case_kind_code desc)
+ from case_ranked), '[]'::json) as cases,
+ coalesce((select json_agg(json_build_object(
+ 'row_kind', row_kind, 'payload', payload)
+ order by post_id, case_kind_code,
+ sort_ordinal, row_kind)
+ from detail), '[]'::json) as details,
+ json_build_object(
+ 'topic_tepp_ready', exists(
+ select 1 from topic_context_membership membership
+ join visible_post on visible_post.source_post_id = membership.source_post_id
+ and visible_post.occurred_at >= membership.valid_from
+ and visible_post.occurred_at < membership.valid_to
+ ),
+ 'topic_fast_ready', exists(select 1 from topic_detail)
+ ) as topic_readiness,
+ coalesce((select json_agg(row_to_json(topic_detail)
+ order by topic_index, dimension_code,
+ context_label, influence_value desc,
+ occurred_at, source_post_id)
+ from topic_detail), '[]'::json) as topic_details
+ from metric
+ """
+
+
async def fetch_operations_dashboard(
conn: _Connection,
corporate_entity_ids: tuple[str, ...] | list[str],
process_unit_ids: tuple[str, ...] | list[str] = (),
period_start: date | None = None,
period_end: date | None = None,
+ external_only: bool = False,
+ source_context_required: bool | None = None,
+ case_cursor: str | None = None,
+ case_limit: int = DASHBOARD_CASE_PAGE_SIZE,
) -> dict[str, Any]:
"""Return quantified cases and their persisted source evidence."""
if period_start and period_end and period_start > period_end:
raise ValueError("period_start must not be after period_end")
- args = (list(corporate_entity_ids), list(process_unit_ids), period_start, period_end)
- visible = _visible_period_sql()
+ if case_limit < 1 or case_limit > DASHBOARD_CASE_PAGE_SIZE_MAX:
+ raise ValueError(
+ f"Request between 1 and {DASHBOARD_CASE_PAGE_SIZE_MAX} Dashboard cases."
+ )
+ cursor = _decode_case_cursor(case_cursor)
+ source_args = (
+ list(corporate_entity_ids), list(process_unit_ids),
+ period_start, period_end, external_only,
+ )
+ args = (
+ [UUID(value) for value in corporate_entity_ids],
+ [UUID(value) for value in process_unit_ids],
+ period_start, period_end, external_only,
+ )
+ cursor_args = cursor or (None, None, None)
+ bundle = await conn.fetchrow(
+ _dashboard_single_statement_sql(source_context_required),
+ *args,
+ *cursor_args,
+ case_limit,
+ json.dumps(
+ {
+ case_kind: sorted(fact_types)
+ for case_kind, fact_types in REQUIRED_FACT_TYPES.items()
+ }
+ ),
+ )
+ conn = _DashboardBundleConnection(bundle)
+ visible = _visible_period_sql(source_context_required=source_context_required)
+ projected_visible = _visible_projection_period_sql(
+ "post", source_context_required=source_context_required
+ )
+ projected_evidence = _visible_projection_scope_sql(
+ "evidence_post", source_context_required=source_context_required
+ )
metrics = await conn.fetchrow(
f"""
- with visible_post as (
- select post.post_id
- from source_post post
- where {visible}
- ), classified as (
- select classification.post_id, classification.case_kind_code
+ with visible_post as materialized (
+ select post.source_post_id as post_id,
+ post.case_analysis_present, post.ingestion_failed
+ from dashboard_post_read_projection post
+ where {projected_visible}
+ ), external_post as (
+ select distinct classification.post_id
from operations_case_classification classification
join visible_post on visible_post.post_id = classification.post_id
+ join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = classification.evidence_post_id
+ where {projected_evidence}
+ and classification.case_kind_code = 'external_information'
)
- select (select count(*) from visible_post) as total_post_count,
- (select count(*) from classified) as total_event_count,
- (select count(distinct post_id) from classified
- where case_kind_code = 'external_information') as external_post_count,
- (select count(*) from visible_post
- where not exists (
- select 1 from operations_case_analysis analysis
- where analysis.post_id = visible_post.post_id
- ) and not exists (
- select 1 from post_content_ingestion_job job
- where job.post_id = visible_post.post_id
- and job.status_code = 'post_content_ingestion_failed'
- )) as pending_analysis_count,
- (select count(*) from visible_post
- where exists (
- select 1 from post_content_ingestion_job job
- where job.post_id = visible_post.post_id
- and job.status_code = 'post_content_ingestion_failed'
- )) as failed_analysis_count
+ select count(*) as total_post_count,
+ count(external_post.post_id) as external_post_count,
+ count(*) filter (
+ where ($5::boolean is false or external_post.post_id is not null)
+ and not visible_post.case_analysis_present
+ and not visible_post.ingestion_failed
+ ) as pending_analysis_count,
+ count(*) filter (
+ where ($5::boolean is false or external_post.post_id is not null)
+ and visible_post.ingestion_failed
+ ) as failed_analysis_count
+ from visible_post
+ left join external_post using (post_id)
+ """,
+ *args,
+ )
+ case_rollup_rows = await conn.fetch(
+ f"""
+ /* dashboard_case_rollup */
+ select rollup.source_post_id as post_id, rollup.case_kind_code,
+ coalesce(milestone.event_count, 0) as event_count,
+ coalesce(milestone.claim_started, false) as claim_started,
+ coalesce(milestone.claim_ended, false) as claim_ended,
+ coalesce(milestone.rebid_started, false) as rebid_started,
+ coalesce(milestone.rebid_ended, false) as rebid_ended,
+ coalesce(milestone.handover_started, false) as handover_started,
+ coalesce(milestone.handover_ended, false) as handover_ended,
+ rollup.claim_start_missing, rollup.rebid_start_missing,
+ rollup.handover_start_missing
+ from dashboard_case_rollup_read_projection rollup
+ join dashboard_post_read_projection post
+ on post.source_post_id = rollup.source_post_id
+ join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = rollup.classification_evidence_post_id
+ left join lateral (
+ select sum(value.event_count) as event_count,
+ bool_or(value.claim_started) as claim_started,
+ bool_or(value.claim_ended) as claim_ended,
+ bool_or(value.rebid_started) as rebid_started,
+ bool_or(value.rebid_ended) as rebid_ended,
+ bool_or(value.handover_started) as handover_started,
+ bool_or(value.handover_ended) as handover_ended
+ from dashboard_case_milestone_read_projection value
+ join dashboard_post_read_projection milestone_evidence
+ on milestone_evidence.source_post_id = value.evidence_post_id
+ where value.source_post_id = rollup.source_post_id
+ and value.case_kind_code = rollup.case_kind_code
+ and {_visible_projection_scope_sql('milestone_evidence', source_context_required=source_context_required)}
+ ) milestone on true
+ where {projected_visible}
+ and {projected_evidence}
+ and ($5::boolean is false
+ or rollup.case_kind_code = 'external_information')
""",
*args,
)
@@ -105,80 +868,815 @@ async def fetch_operations_dashboard(
classification.summary_text, classification.evidence_text,
classification.evidence_post_id,
coalesce(post.event_occurred_at, post.created_at) as occurred_at,
- coalesce(nullif(btrim(post.source_project_name), ''), project.project_name)
- as project_name
+ coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name,
+ nullif(btrim(post.source_project_code), ''))
+ as project_name,
+ coalesce(project.project_names, array[]::text[]) as project_names,
+ coalesce(project_identity.project_keys, array[]::text[]) as project_keys,
+ coalesce(project_identity.project_key_labels, array[]::text[]) as project_key_labels,
+ coalesce(project_identity.project_key_provenances, array[]::text[])
+ as project_key_provenances
from operations_case_classification classification
- join source_post post on post.post_id = classification.post_id
+ join dashboard_post_read_projection post_scope
+ on post_scope.source_post_id = classification.post_id
+ join source_post post on post.post_id = post_scope.source_post_id
+ join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = classification.evidence_post_id
left join lateral (
- select mention.project_name
- from post_project_mention mention
- where mention.post_id = post.post_id
- order by mention.confidence desc, mention.project_name, mention.project_key
- limit 1
+ select array_agg(names.project_name order by names.project_name) as project_names,
+ (
+ select nullif(btrim(primary_mention.project_name), '')
+ from post_project_mention primary_mention
+ where primary_mention.post_id = post.post_id
+ and nullif(btrim(primary_mention.project_name), '') is not null
+ order by primary_mention.confidence desc,
+ primary_mention.project_name
+ limit 1
+ ) as primary_project_name
+ from (
+ select coalesce(nullif(btrim(post.source_project_name), ''),
+ nullif(btrim(post.source_project_code), '')) as project_name
+ union
+ select nullif(btrim(mention.project_name), '')
+ from post_project_mention mention
+ where mention.post_id = post.post_id
+ ) names
+ where names.project_name is not null
) project on true
- where {visible}
+ {_project_identity_lateral_sql('post', 'post_id')}
+ where {_visible_projection_period_sql('post_scope', source_context_required=source_context_required)}
+ and {projected_evidence}
+ and ($5::boolean is false or classification.case_kind_code = 'external_information')
+ and ($6::timestamptz is null
+ or (coalesce(post.event_occurred_at, post.created_at),
+ classification.post_id, classification.case_kind_code)
+ < ($6, $7::uuid, $8::text))
order by coalesce(post.event_occurred_at, post.created_at) desc,
- classification.post_id, classification.case_kind_code
+ classification.post_id desc, classification.case_kind_code desc
+ limit $9
""",
*args,
+ *cursor_args,
+ case_limit + 1,
)
- fact_rows = await conn.fetch(
+ has_more_cases = len(case_rows) > case_limit
+ if has_more_cases:
+ case_rows = case_rows[:case_limit]
+ next_case_cursor = _encode_case_cursor(case_rows[-1]) if has_more_cases else None
+ selected_post_ids = [str(row["post_id"]) for row in case_rows]
+ selected_case_kinds = [row["case_kind_code"] for row in case_rows]
+ selected_args = (args[0], args[1], selected_post_ids, selected_case_kinds)
+ detail_bundle_rows = await conn.fetch(
f"""
- select fact.post_id, fact.case_kind_code, fact.fact_type_code,
- fact.value_text, fact.evidence_text, fact.evidence_post_id,
- fact.fact_ordinal
- from operations_case_fact fact
- join source_post post on post.post_id = fact.post_id
- where {visible}
- order by fact.post_id, fact.case_kind_code, fact.fact_ordinal
+ with selected_case as (
+ select * from unnest($3::uuid[], $4::text[])
+ as selected(post_id, case_kind_code)
+ ), detail as (
+ select 'fact'::text as row_kind,
+ jsonb_build_object(
+ 'post_id', fact.post_id, 'case_kind_code', fact.case_kind_code,
+ 'fact_type_code', fact.fact_type_code, 'value_text', fact.value_text,
+ 'evidence_text', fact.evidence_text,
+ 'evidence_post_id', fact.evidence_post_id,
+ 'fact_ordinal', fact.fact_ordinal,
+ 'relation_target_kind_code', fact.relation_target_kind_code
+ ) as payload,
+ fact.post_id as sort_post_id, fact.case_kind_code as sort_case_kind,
+ fact.fact_ordinal::bigint as sort_ordinal
+ from selected_case selected
+ join operations_case_fact fact using (post_id, case_kind_code)
+ join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = fact.evidence_post_id
+ where {projected_evidence}
+ union all
+ select 'product',
+ jsonb_build_object(
+ 'post_id', relation.post_id, 'case_kind_code', relation.case_kind_code,
+ 'fact_ordinal', relation.fact_ordinal,
+ 'relation_type_code', relation.relation_type_code,
+ 'extracted_product_name', mention.extracted_product_name,
+ 'canonical_product_name', catalog.canonical_product_name,
+ 'evidence_text', relation.evidence_text,
+ 'evidence_post_id', relation.evidence_post_id
+ ), relation.post_id, relation.case_kind_code,
+ relation.fact_ordinal::bigint
+ from selected_case selected
+ join product_operations_fact_relation relation using (post_id, case_kind_code)
+ join post_product_analysis product_analysis
+ on product_analysis.post_id = relation.post_id
+ and product_analysis.orchestrator_model_receipt is not null
+ join post_content_ingestion_job product_job
+ on product_job.post_id = relation.post_id
+ and product_job.source_body_sha256 = product_analysis.source_body_sha256
+ join post_product_mention mention
+ on mention.post_id = relation.post_id
+ and mention.mention_ordinal = relation.mention_ordinal
+ left join product_catalog catalog
+ on catalog.product_catalog_id = mention.product_catalog_id
+ join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = relation.evidence_post_id
+ where {projected_evidence}
+ union all
+ select 'missing_fact',
+ jsonb_build_object(
+ 'post_id', missing.post_id, 'case_kind_code', missing.case_kind_code,
+ 'fact_type_code', missing.fact_type_code
+ ), missing.post_id, missing.case_kind_code, 0
+ from selected_case selected
+ join operations_case_missing_fact missing using (post_id, case_kind_code)
+ union all
+ select 'missing_fact',
+ jsonb_build_object(
+ 'post_id', fact.post_id, 'case_kind_code', fact.case_kind_code,
+ 'fact_type_code', fact.fact_type_code
+ ), fact.post_id, fact.case_kind_code, fact.fact_ordinal::bigint
+ from selected_case selected
+ join operations_case_fact fact using (post_id, case_kind_code)
+ left join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = fact.evidence_post_id
+ where (evidence_post.source_post_id is null or not ({projected_evidence}))
+ and ($5::jsonb -> fact.case_kind_code) ? fact.fact_type_code
+ union all
+ select 'milestone',
+ jsonb_build_object(
+ 'post_id', milestone.post_id,
+ 'case_kind_code', milestone.case_kind_code,
+ 'milestone_type_code', milestone.milestone_type_code,
+ 'evidence_text', milestone.evidence_text,
+ 'evidence_post_id', milestone.evidence_post_id,
+ 'observed_at', milestone.observed_at,
+ 'time_axis_code', milestone.time_axis_code,
+ 'is_missing', false
+ ), milestone.post_id, milestone.case_kind_code,
+ (extract(epoch from milestone.observed_at) * 1000000)::bigint
+ from selected_case selected
+ join operations_case_milestone milestone using (post_id, case_kind_code)
+ join dashboard_post_read_projection evidence_post
+ on evidence_post.source_post_id = milestone.evidence_post_id
+ where {projected_evidence}
+ union all
+ select 'milestone',
+ jsonb_build_object(
+ 'post_id', missing.post_id,
+ 'case_kind_code', missing.case_kind_code,
+ 'milestone_type_code', missing.milestone_type_code,
+ 'evidence_text', null, 'evidence_post_id', null,
+ 'observed_at', null, 'time_axis_code', null,
+ 'is_missing', true
+ ), missing.post_id, missing.case_kind_code, 9223372036854775807
+ from selected_case selected
+ join operations_case_missing_milestone missing using (post_id, case_kind_code)
+ )
+ select row_kind, payload
+ from detail
+ order by sort_post_id, sort_case_kind, sort_ordinal, row_kind
""",
- *args,
+ *selected_args,
+ json.dumps(
+ {
+ case_kind: sorted(fact_types)
+ for case_kind, fact_types in REQUIRED_FACT_TYPES.items()
+ }
+ ),
)
- facts: dict[tuple[str, str], list[dict[str, str]]] = {}
+ fact_rows: list[dict[str, Any]] = []
+ product_relation_rows: list[dict[str, Any]] = []
+ missing_rows: list[dict[str, Any]] = []
+ milestone_rows: list[dict[str, Any]] = []
+ detail_targets = {
+ "fact": fact_rows,
+ "product": product_relation_rows,
+ "missing_fact": missing_rows,
+ "milestone": milestone_rows,
+ }
+ for bundle_row in detail_bundle_rows:
+ payload = bundle_row["payload"]
+ detail_targets[bundle_row["row_kind"]].append(
+ json.loads(payload) if isinstance(payload, str) else dict(payload)
+ )
+ topic_context = (
+ {
+ "status_code": "not_applicable",
+ "reason_code": "external_information_view",
+ "next_action": "전체 Dashboard로 전환해 주요 글과 조직별 변화를 확인하세요.",
+ "required_contracts": [],
+ "model_run": None,
+ "topics": [],
+ }
+ if external_only
+ else await _fetch_topic_context_dashboard(conn, visible, source_args[:4])
+ )
+ product_relations: dict[tuple[str, str, int], list[dict[str, str]]] = {}
+ for row in product_relation_rows:
+ relation_key = (
+ str(row["post_id"]),
+ row["case_kind_code"],
+ int(row["fact_ordinal"]),
+ )
+ product_relations.setdefault(relation_key, []).append(
+ {
+ "relation_type_code": row["relation_type_code"],
+ "product_name": row["canonical_product_name"]
+ or row["extracted_product_name"],
+ "evidence_text": row["evidence_text"],
+ "evidence_post_id": str(row["evidence_post_id"]),
+ }
+ )
+ facts: dict[tuple[str, str], list[dict[str, Any]]] = {}
for row in fact_rows:
key = (str(row["post_id"]), row["case_kind_code"])
- facts.setdefault(key, []).append(
+ projected_fact = {
+ "fact_type_code": row["fact_type_code"],
+ "fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]],
+ "value_text": row["value_text"],
+ "evidence_text": row["evidence_text"],
+ "evidence_post_id": str(row["evidence_post_id"]),
+ "ontology_class_iri": str(LW.OperationsCaseFact),
+ "provenance_relation_iri": PROV_WAS_DERIVED_FROM,
+ }
+ related_products = product_relations.get(
+ (str(row["post_id"]), row["case_kind_code"], int(row["fact_ordinal"])),
+ [],
+ )
+ if related_products:
+ projected_fact["product_relations"] = related_products
+ target_kind = row["relation_target_kind_code"]
+ if target_kind in EXTERNAL_RELATION_TARGETS:
+ target_label, target_class, predicate = EXTERNAL_RELATION_TARGETS[target_kind]
+ projected_fact["relation_target_kind_code"] = target_kind
+ projected_fact["relation_target_kind_label"] = target_label
+ projected_fact["relation_target_class_iri"] = target_class
+ projected_fact["relation_predicate_iri"] = predicate
+ facts.setdefault(key, []).append(projected_fact)
+ missing_facts: dict[tuple[str, str], list[dict[str, str]]] = {}
+ for row in missing_rows:
+ key = (str(row["post_id"]), row["case_kind_code"])
+ missing_facts.setdefault(key, []).append(
{
"fact_type_code": row["fact_type_code"],
"fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]],
- "value_text": row["value_text"],
+ }
+ )
+ milestones: dict[tuple[str, str], list[dict[str, Any]]] = {}
+ missing_milestones: dict[tuple[str, str], set[str]] = {}
+ for row in milestone_rows:
+ key = (str(row["post_id"]), row["case_kind_code"])
+ if row["is_missing"]:
+ missing_milestones.setdefault(key, set()).add(row["milestone_type_code"])
+ continue
+ observed_at = row["observed_at"]
+ milestones.setdefault(key, []).append(
+ {
+ "milestone_type_code": row["milestone_type_code"],
+ "milestone_type_label": MILESTONE_TYPE_LABELS[
+ row["milestone_type_code"]
+ ],
"evidence_text": row["evidence_text"],
"evidence_post_id": str(row["evidence_post_id"]),
+ "observed_at": (
+ observed_at if isinstance(observed_at, str) else observed_at.isoformat()
+ ),
+ "time_axis_code": row["time_axis_code"],
+ "time_axis_label": (
+ "사건 발생일"
+ if row["time_axis_code"] == "event_occurred_at"
+ else "기록 생성일"
+ ),
}
)
total = int(metrics["total_post_count"])
+ case_post_counts: dict[str, int] = {}
+ case_event_counts: dict[str, int] = {}
+ for row in case_rollup_rows:
+ kind = row["case_kind_code"]
+ case_post_counts[kind] = case_post_counts.get(kind, 0) + int(
+ row.get("post_count", 1)
+ )
+ case_event_counts[kind] = case_event_counts.get(kind, 0) + int(
+ row["event_count"]
+ )
external = int(metrics["external_post_count"])
- return {
- "period_label": _period_label(period_start, period_end),
- "total_post_count": total,
- "total_event_count": int(metrics["total_event_count"]),
- "external_post_count": external,
- "external_percent": external * 100 / total if total else 0.0,
- "pending_analysis_count": int(metrics["pending_analysis_count"]),
- "failed_analysis_count": int(metrics["failed_analysis_count"]),
- "cases": [
+ pending_analysis_count = int(metrics["pending_analysis_count"])
+ failed_analysis_count = int(metrics["failed_analysis_count"])
+ projected_cases = []
+ lifecycle_metrics = {
+ lifecycle_code: {
+ "lifecycle_kind_code": lifecycle_code,
+ "lifecycle_kind_label": label,
+ "open_case_count": 0,
+ "resolved_case_count": 0,
+ "evidence_missing_case_count": 0,
+ }
+ for lifecycle_code, _kind, label, _start, _end in LIFECYCLE_DEFINITIONS
+ }
+ for row in case_rollup_rows:
+ for lifecycle_code, required_kind, _label, start_code, end_code in LIFECYCLE_DEFINITIONS:
+ if row["case_kind_code"] != required_kind:
+ continue
+ prefix = lifecycle_code.removesuffix("_investigation").removesuffix("_response").removesuffix("_gap")
+ if f"{prefix}_resolved" in row:
+ lifecycle_metrics[lifecycle_code]["resolved_case_count"] += int(
+ row[f"{prefix}_resolved"]
+ )
+ lifecycle_metrics[lifecycle_code]["open_case_count"] += int(
+ row[f"{prefix}_open"]
+ )
+ lifecycle_metrics[lifecycle_code]["evidence_missing_case_count"] += int(
+ row[f"{prefix}_missing"]
+ )
+ continue
+ started = bool(row[f"{prefix}_started"])
+ ended = bool(row[f"{prefix}_ended"])
+ start_missing = bool(row[f"{prefix}_start_missing"])
+ status_code = "resolved" if started and ended else "open" if started else "evidence_missing"
+ if start_missing:
+ status_code = "evidence_missing"
+ lifecycle_metrics[lifecycle_code][f"{status_code}_case_count"] += 1
+ for row in case_rows:
+ key = (str(row["post_id"]), row["case_kind_code"])
+ case_milestones = milestones.get(key, [])
+ case_lifecycles = _project_lifecycles(
+ row["case_kind_code"], case_milestones, missing_milestones.get(key, set())
+ )
+ projected_cases.append(
{
"post_id": str(row["post_id"]),
"case_kind_code": row["case_kind_code"],
"case_kind_label": CASE_KIND_LABELS[row["case_kind_code"]],
"project_name": row["project_name"],
+ "project_names": list(row["project_names"]),
+ "projects": [
+ {
+ "project_key": project_key,
+ "project_name": project_name,
+ "key_provenance": key_provenance,
+ "evidence_post_id": str(row["post_id"]),
+ }
+ for project_key, project_name, key_provenance in zip(
+ row["project_keys"],
+ row["project_key_labels"],
+ row["project_key_provenances"],
+ strict=True,
+ )
+ ],
"summary_text": row["summary_text"],
"evidence_text": row["evidence_text"],
"evidence_post_id": str(row["evidence_post_id"]),
+ "ontology_class_iri": CASE_KIND_ONTOLOGY_CLASSES[row["case_kind_code"]],
+ "provenance_relation_iri": PROV_WAS_DERIVED_FROM,
+ "occurred_at": row["occurred_at"].isoformat(),
+ "facts": facts.get(key, []),
+ "missing_facts": missing_facts.get(key, []),
+ "milestones": case_milestones,
+ "lifecycles": case_lifecycles,
+ "semantic_projection": _operations_case_jsonld(
+ str(row["post_id"]), row["case_kind_code"],
+ str(row["evidence_post_id"]), facts.get(key, []),
+ ),
+ }
+ )
+ return {
+ "period_label": _period_label(period_start, period_end),
+ "period_start": period_start.isoformat() if period_start else None,
+ "period_end": period_end.isoformat() if period_end else None,
+ "project_history_knowledge_cutoff": (
+ (
+ datetime.combine(period_end, time.max, tzinfo=ZoneInfo("Asia/Seoul"))
+ ).isoformat()
+ if period_end
+ else None
+ ),
+ "period_time_axis_code": "event_occurred_at",
+ "total_post_count": total,
+ "total_event_count": sum(case_event_counts.values()),
+ "external_post_count": external,
+ "external_percent": external * 100 / total if total else 0.0,
+ "pending_analysis_count": pending_analysis_count,
+ "failed_analysis_count": failed_analysis_count,
+ "case_metrics": [
+ {
+ "case_kind_code": kind,
+ "case_kind_label": label,
+ "event_count": case_event_counts.get(kind, 0),
+ "post_count": case_post_counts.get(kind, 0),
+ }
+ for kind, label in CASE_KIND_LABELS.items()
+ ],
+ "topic_context": topic_context,
+ "lifecycle_metrics": list(lifecycle_metrics.values()),
+ "cases": projected_cases,
+ "next_case_cursor": next_case_cursor,
+ }
+
+
+async def warm_operations_dashboard_read_statements(conn: _Connection) -> None:
+ """Prepare the bounded Dashboard query shapes before serving requests."""
+ required_facts = json.dumps(
+ {
+ case_kind: sorted(fact_types)
+ for case_kind, fact_types in REQUIRED_FACT_TYPES.items()
+ }
+ )
+ for source_context_required in (None, True, False):
+ await conn.fetchrow(
+ _dashboard_single_statement_sql(source_context_required),
+ [], [], None, None, False, None, None, None, 20, required_facts,
+ )
+
+
+def _project_lifecycles(
+ case_kind_code: str,
+ milestones: list[dict[str, Any]],
+ missing_milestones: set[str],
+) -> list[dict[str, Any]]:
+ """Pair observed endpoints and report exact elapsed time without thresholds."""
+ by_type = {value["milestone_type_code"]: value for value in milestones}
+ result = []
+ for lifecycle_code, required_kind, label, start_code, end_code in LIFECYCLE_DEFINITIONS:
+ if case_kind_code != required_kind:
+ continue
+ start = by_type.get(start_code)
+ end = by_type.get(end_code)
+ if start and end:
+ elapsed_seconds = int((datetime.fromisoformat(end["observed_at"]) - datetime.fromisoformat(start["observed_at"])).total_seconds())
+ status_code = "resolved"
+ next_action = "시작·종료 사건 근거를 열어 경과 시간을 검토하세요."
+ elif start:
+ elapsed_seconds = None
+ status_code = "open"
+ next_action = f"{MILESTONE_TYPE_LABELS[end_code]} Event 근거를 연결하세요."
+ else:
+ elapsed_seconds = None
+ status_code = "evidence_missing"
+ next_action = f"{MILESTONE_TYPE_LABELS[start_code]} Event 근거를 연결하세요."
+ result.append({
+ "lifecycle_kind_code": lifecycle_code,
+ "lifecycle_kind_label": label,
+ "status_code": status_code,
+ "status_label": {"resolved": "종료 확인", "open": "진행 중", "evidence_missing": "측정 근거 부족"}[status_code],
+ "started_at": start["observed_at"] if start else None,
+ "resolved_at": end["observed_at"] if end else None,
+ "elapsed_seconds": elapsed_seconds,
+ "start_milestone": start,
+ "end_milestone": end,
+ "next_action_text": next_action,
+ })
+ return result
+
+
+def _unavailable_topic_context(tepp_ready: bool) -> dict[str, Any]:
+ """Return the existing fail-closed topic-context state."""
+ return {
+ "status_code": "unavailable",
+ "reason_code": (
+ "fast_mlsirm_influence_not_persisted"
+ if tepp_ready
+ else "tepp_topic_posterior_not_persisted"
+ ),
+ "next_action": (
+ "선택한 범위의 글 영향도 분석 결과를 먼저 완료하세요."
+ if tepp_ready
+ else "선택한 범위의 시간 흐름 분석 결과를 먼저 완료하세요."
+ ),
+ "required_contracts": [
+ {
+ "authority": "TEPP",
+ "schema_version": "tepp.topic_context_posterior.v1",
+ "state_code": "persisted" if tepp_ready else "not_persisted",
+ },
+ {
+ "authority": "fast-mlsirm",
+ "schema_version": "fast_mlsirm.topic_context_influence.v1",
+ "state_code": "not_persisted",
+ },
+ ],
+ "model_run": None,
+ "topics": [],
+ }
+
+
+async def _fetch_topic_context_dashboard(
+ conn: _Connection,
+ visible_post_sql: str,
+ args: tuple[object, ...],
+) -> dict[str, Any]:
+ """Project exact accepted producer rows or an actionable unavailable state."""
+ authorized_model_scope = """
+ ((scope.scope_kind_code = 'analysis_scope_corporate_entity'
+ and scope.corporate_entity_id::text = any($1::text[])
+ and cardinality($2::text[]) = 0)
+ or
+ (scope.scope_kind_code = 'analysis_scope_process_unit'
+ and scope.process_unit_id::text = any($2::text[])))
+ """
+ readiness = await conn.fetchrow(
+ f"""
+ with visible_post as (
+ select post.post_id,
+ coalesce(post.event_occurred_at, post.created_at) as occurred_at
+ from source_post post
+ where {visible_post_sql}
+ )
+ select exists (
+ select 1
+ from topic_context_membership membership
+ join topic_model_run model
+ on model.topic_model_run_id = membership.topic_model_run_id
+ join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id
+ join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id
+ join visible_post on visible_post.post_id = membership.source_post_id
+ where visible_post.occurred_at >= membership.valid_from
+ and visible_post.occurred_at < membership.valid_to
+ and {authorized_model_scope}
+ ) as tepp_posterior_persisted,
+ exists (
+ select 1
+ from topic_post_context_influence influence
+ join topic_context_membership membership
+ on membership.topic_model_run_id = influence.topic_model_run_id
+ and membership.topic_context_membership_id = influence.topic_context_membership_id
+ join topic_model_run model
+ on model.topic_model_run_id = influence.topic_model_run_id
+ join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id
+ join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id
+ join visible_post on visible_post.post_id = membership.source_post_id
+ join topic_activity_interval activity
+ on activity.topic_model_run_id = influence.topic_model_run_id
+ and activity.topic_index = influence.topic_index
+ and visible_post.occurred_at >= activity.valid_from
+ and visible_post.occurred_at < activity.valid_to
+ where visible_post.occurred_at >= membership.valid_from
+ and visible_post.occurred_at < membership.valid_to
+ and {authorized_model_scope}
+ ) as fast_mlsirm_influence_persisted
+ """,
+ *args,
+ )
+ tepp_ready = bool(readiness and readiness["tepp_posterior_persisted"])
+ fast_mlsirm_ready = bool(
+ readiness and readiness["fast_mlsirm_influence_persisted"]
+ )
+ if not tepp_ready or not fast_mlsirm_ready:
+ return _unavailable_topic_context(tepp_ready)
+
+ rows = await conn.fetch(
+ f"""
+ with visible_post as (
+ select post.post_id,
+ coalesce(post.event_occurred_at, post.created_at) as occurred_at
+ from source_post post
+ where {visible_post_sql}
+ ), candidate_runs as (
+ select model.topic_model_run_id,
+ influence_run.topic_influence_run_id,
+ influence_run.accepted_at
+ from topic_model_run model
+ join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id
+ join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id
+ join topic_influence_run influence_run
+ on influence_run.topic_model_run_id = model.topic_model_run_id
+ where {authorized_model_scope}
+ ), selected as (
+ select *
+ from candidate_runs
+ order by accepted_at desc, topic_model_run_id, topic_influence_run_id
+ limit 1
+ ), eligible as (
+ select model.topic_model_run_id, model.tepp_run_id, model.tepp_snapshot_id,
+ model.tepp_schema_version, model.tepp_model_contract_version,
+ model.tepp_artifact_sha256, model.posterior_draw_set_id,
+ model.posterior_draw_count, model.topic_count,
+ snapshot.snapshot_sha256 as source_snapshot_sha256,
+ analysis.knowledge_cutoff,
+ influence_run.topic_influence_run_id,
+ influence_run.fast_mlsirm_schema_version,
+ influence_run.fast_mlsirm_version,
+ influence_run.fast_mlsirm_code_revision,
+ influence_run.fast_mlsirm_artifact_sha256,
+ influence_run.compute_backend_code,
+ influence_run.precision_code,
+ influence_run.membership_fingerprint_sha256,
+ influence.topic_index, activity.state_code,
+ activity.valid_from as activity_valid_from,
+ activity.valid_to as activity_valid_to,
+ membership.dimension_code, membership.context_id,
+ context.context_label, membership.membership_weight,
+ membership_evidence.node_id as membership_evidence_post_id,
+ membership.source_post_id, visible_post.occurred_at,
+ influence.influence_value,
+ influence.uncertainty_method_code,
+ influence.uncertainty_lower_value,
+ influence.uncertainty_upper_value,
+ influence.diagnostic_status_code,
+ influence_run.accepted_at,
+ visible_post.post_id is not null
+ and activity.topic_model_run_id is not null
+ and membership_evidence_visible.post_id is not null
+ and not exists (
+ select 1
+ from topic_lineage_relation checked_relation
+ left join provenance_assertion checked_assertion
+ on checked_assertion.assertion_id = checked_relation.provenance_assertion_id
+ left join provenance_resource_binding checked_evidence
+ on checked_evidence.resource_id = checked_assertion.object_resource_id
+ and checked_evidence.node_type_code = 'node_post'
+ left join visible_post checked_visible
+ on checked_visible.post_id = checked_evidence.node_id
+ where checked_relation.topic_model_run_id = selected.topic_model_run_id
+ and checked_visible.post_id is null
+ ) as provenance_complete
+ from topic_post_context_influence influence
+ join topic_influence_run influence_run
+ on influence_run.topic_model_run_id = influence.topic_model_run_id
+ and influence_run.topic_influence_run_id = influence.topic_influence_run_id
+ join selected
+ on selected.topic_model_run_id = influence.topic_model_run_id
+ and selected.topic_influence_run_id = influence.topic_influence_run_id
+ join topic_model_run model
+ on model.topic_model_run_id = selected.topic_model_run_id
+ join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id
+ join analysis_source_snapshot snapshot
+ on snapshot.analysis_source_snapshot_id = analysis.analysis_source_snapshot_id
+ join topic_context_membership membership
+ on membership.topic_model_run_id = influence.topic_model_run_id
+ and membership.topic_context_membership_id = influence.topic_context_membership_id
+ join topic_context_definition context
+ on context.topic_model_run_id = membership.topic_model_run_id
+ and context.dimension_code = membership.dimension_code
+ and context.context_id = membership.context_id
+ left join visible_post on visible_post.post_id = membership.source_post_id
+ left join provenance_assertion membership_assertion
+ on membership_assertion.assertion_id = membership.provenance_assertion_id
+ left join provenance_resource_binding membership_evidence
+ on membership_evidence.resource_id = membership_assertion.object_resource_id
+ and membership_evidence.node_type_code = 'node_post'
+ left join visible_post membership_evidence_visible
+ on membership_evidence_visible.post_id = membership_evidence.node_id
+ left join topic_activity_interval activity
+ on activity.topic_model_run_id = influence.topic_model_run_id
+ and activity.topic_index = influence.topic_index
+ and visible_post.occurred_at >= activity.valid_from
+ and visible_post.occurred_at < activity.valid_to
+ and visible_post.occurred_at >= membership.valid_from
+ and visible_post.occurred_at < membership.valid_to
+ )
+ select eligible.*,
+ coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'event_code', relation.event_code,
+ 'source_topic_index', relation.source_topic_index,
+ 'target_topic_index', relation.target_topic_index,
+ 'event_time', relation.event_time,
+ 'evidence_post_id', relation_evidence.node_id
+ ) order by relation.event_time, relation.relation_ordinal)
+ from topic_lineage_relation relation
+ join provenance_assertion relation_assertion
+ on relation_assertion.assertion_id = relation.provenance_assertion_id
+ join provenance_resource_binding relation_evidence
+ on relation_evidence.resource_id = relation_assertion.object_resource_id
+ and relation_evidence.node_type_code = 'node_post'
+ join visible_post relation_evidence_visible
+ on relation_evidence_visible.post_id = relation_evidence.node_id
+ where relation.topic_model_run_id = eligible.topic_model_run_id
+ and (relation.source_topic_index = eligible.topic_index
+ or relation.target_topic_index = eligible.topic_index)
+ ), '[]'::jsonb) as lineage_events
+ from eligible
+ order by eligible.topic_index,
+ case eligible.dimension_code
+ when 'business_unit' then 0
+ when 'process_unit' then 1
+ when 'team' then 2
+ else 3
+ end,
+ eligible.context_label,
+ eligible.influence_value desc,
+ eligible.occurred_at,
+ eligible.source_post_id
+ """,
+ *args,
+ )
+ if not rows:
+ # The readiness query can see an accepted influence row from a
+ # different selected run than the projection query. In an empty
+ # projection, report fast-mlsirm as unavailable for this exact
+ # visible/time window rather than claiming a persisted contract.
+ return _unavailable_topic_context(tepp_ready)
+
+ if not all(bool(row["provenance_complete"]) for row in rows):
+ return {
+ "status_code": "unavailable",
+ "reason_code": "topic_context_provenance_not_navigable",
+ "next_action": "조직 소속과 주제 변화의 근거 글 연결을 완료한 뒤 다시 확인하세요.",
+ "required_contracts": [
+ {
+ "authority": "TEPP",
+ "schema_version": rows[0]["tepp_schema_version"],
+ "state_code": "evidence_link_unavailable",
+ },
+ {
+ "authority": "fast-mlsirm",
+ "schema_version": rows[0]["fast_mlsirm_schema_version"],
+ "state_code": "evidence_link_unavailable",
+ },
+ ],
+ "model_run": None,
+ "topics": [],
+ }
+
+ first = rows[0]
+ topics: dict[int, dict[str, Any]] = {}
+ for row in rows:
+ topic_index = int(row["topic_index"])
+ raw_lineage_events = row["lineage_events"]
+ lineage_events = (
+ json.loads(raw_lineage_events)
+ if isinstance(raw_lineage_events, str)
+ else list(raw_lineage_events)
+ )
+ topic = topics.setdefault(
+ topic_index,
+ {
+ "topic_index": topic_index,
+ "activity_intervals": [],
+ "lineage_events": lineage_events,
+ "contexts": [],
+ },
+ )
+ interval = {
+ "state_code": row["state_code"],
+ "valid_from": row["activity_valid_from"].isoformat(),
+ "valid_to": row["activity_valid_to"].isoformat(),
+ }
+ if interval not in topic["activity_intervals"]:
+ topic["activity_intervals"].append(interval)
+ context_key = (row["dimension_code"], row["context_id"])
+ context = next(
+ (
+ item
+ for item in topic["contexts"]
+ if (item["dimension_code"], item["context_id"]) == context_key
+ ),
+ None,
+ )
+ if context is None:
+ context = {
+ "dimension_code": row["dimension_code"],
+ "context_id": row["context_id"],
+ "context_label": row["context_label"],
+ "influences": [],
+ }
+ topic["contexts"].append(context)
+ context["influences"].append(
+ {
+ "post_id": str(row["source_post_id"]),
"occurred_at": row["occurred_at"].isoformat(),
- "facts": facts.get((str(row["post_id"]), row["case_kind_code"]), []),
+ "topic_state_code": row["state_code"],
+ "model_influence": float(row["influence_value"]),
+ "uncertainty_method_code": row["uncertainty_method_code"],
+ "uncertainty_lower_value": float(row["uncertainty_lower_value"]),
+ "uncertainty_upper_value": float(row["uncertainty_upper_value"]),
+ "diagnostic_status_code": row["diagnostic_status_code"],
+ "membership_weight": float(row["membership_weight"]),
+ "membership_evidence_post_id": str(row["membership_evidence_post_id"]),
}
- for row in case_rows
+ )
+
+ return {
+ "status_code": "accepted",
+ "reason_code": None,
+ "next_action": "주제와 조직 범위를 선택해 영향이 큰 글과 근거를 확인하세요.",
+ "required_contracts": [
+ {"authority": "TEPP", "schema_version": first["tepp_schema_version"], "state_code": "persisted"},
+ {"authority": "fast-mlsirm", "schema_version": first["fast_mlsirm_schema_version"], "state_code": "persisted"},
],
+ "model_run": {
+ "tepp_run_id": first["tepp_run_id"],
+ "tepp_snapshot_id": first["tepp_snapshot_id"],
+ "source_snapshot_sha256": first["source_snapshot_sha256"],
+ "knowledge_cutoff": first["knowledge_cutoff"].isoformat(),
+ "tepp_model_contract_version": first["tepp_model_contract_version"],
+ "tepp_artifact_sha256": first["tepp_artifact_sha256"],
+ "posterior_draw_set_id": first["posterior_draw_set_id"],
+ "posterior_draw_count": int(first["posterior_draw_count"]),
+ "topic_count": int(first["topic_count"]),
+ "fast_mlsirm_version": first["fast_mlsirm_version"],
+ "fast_mlsirm_code_revision": first["fast_mlsirm_code_revision"],
+ "fast_mlsirm_artifact_sha256": first["fast_mlsirm_artifact_sha256"],
+ "compute_backend_code": first["compute_backend_code"],
+ "precision_code": first["precision_code"],
+ "membership_fingerprint_sha256": first["membership_fingerprint_sha256"],
+ },
+ "topics": list(topics.values()),
}
def _period_label(period_start: date | None, period_end: date | None) -> str:
"""Format the exact event-time interval represented by the projection."""
if period_start and period_end:
- return f"{period_start.isoformat()} ~ {period_end.isoformat()} · Event 발생일"
+ return f"{period_start.isoformat()} ~ {period_end.isoformat()} · 사건 발생일"
if period_start:
- return f"{period_start.isoformat()} 이후 · Event 발생일"
+ return f"{period_start.isoformat()} 이후 · 사건 발생일"
if period_end:
- return f"{period_end.isoformat()} 이전 · Event 발생일"
- return "전체 기간 · Event 발생일"
+ return f"{period_end.isoformat()} 이전 · 사건 발생일"
+ return "전체 기간 · 사건 발생일"
diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py
index 6928a7f7d..d5b87838d 100644
--- a/backend/app/post_chat_ingestion.py
+++ b/backend/app/post_chat_ingestion.py
@@ -323,6 +323,30 @@ async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> Linked
return LinkedPostIds(direct=direct_ids - {post_id}, indirect=indirect_ids - direct_ids)
+async def find_project_sibling_post_ids(
+ conn: asyncpg.Connection, post_id: str
+) -> frozenset[str]:
+ """Return published posts sharing the focal post's persisted project key."""
+ project_rows = await conn.fetch(
+ "select distinct project_key from post_project_mention where post_id = $1",
+ post_id,
+ )
+ project_keys = [str(row["project_key"]) for row in project_rows]
+ if not project_keys:
+ return frozenset()
+ rows = await conn.fetch(
+ "select distinct ppm.post_id from post_project_mention ppm "
+ "join source_post sp on sp.post_id = ppm.post_id "
+ "where ppm.project_key = any($1::text[]) and ppm.post_id <> $2 "
+ f"and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='sp')} "
+ "order by ppm.post_id limit $3",
+ project_keys,
+ post_id,
+ _POST_CHAT_CANDIDATE_LIMIT,
+ )
+ return frozenset(str(row["post_id"]) for row in rows)
+
+
async def gather_chat_sources(
conn: asyncpg.Connection,
post_id: str,
@@ -364,9 +388,11 @@ async def gather_chat_sources(
)
linked = await find_linked_post_ids(conn, post_id)
+ project_sibling_ids = await find_project_sibling_post_ids(conn, post_id)
candidate_ids = [
- *sorted(linked.direct),
- *sorted(linked.indirect),
+ *sorted(project_sibling_ids),
+ *sorted(linked.direct - project_sibling_ids),
+ *sorted(linked.indirect - project_sibling_ids),
][:_POST_CHAT_CANDIDATE_LIMIT]
if not candidate_ids:
graph_facts = await _graph_facts_for_posts(conn, [source_id])
@@ -967,6 +993,9 @@ async def gather_global_chat_sources(
source_arguments["external_claim_facts"] = (
semantic_facts.get(post_id, ()) + post_graph_facts
)
+ event_occurred_at = row.get("event_occurred_at")
+ created_at = row.get("created_at")
+ observed_at = event_occurred_at or created_at
sources.append(
source_type(
post_id,
@@ -993,6 +1022,14 @@ async def gather_global_chat_sources(
if historical_body_unavailable
else (("semantic_role", "semantic_keyman", "knowledge_graph", "lineage", "image") if knowledge_cutoff else ())
),
+ observed_at=observed_at.isoformat() if observed_at else None,
+ time_axis_code=(
+ "event_occurred_at"
+ if event_occurred_at is not None
+ else "created_at"
+ if created_at is not None
+ else None
+ ),
evidence_open_action=(
None
if post_id in lineage_neighbor_id_set
diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py
index 43e44e015..8c4cd588f 100644
--- a/backend/app/post_content_queue.py
+++ b/backend/app/post_content_queue.py
@@ -3,13 +3,14 @@
from __future__ import annotations
import hashlib
-from datetime import timedelta
from dataclasses import dataclass
+from datetime import datetime, timedelta
from typing import Any
import asyncpg
import redis.asyncio as redis
+from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from lineageweave.observability import traced
POST_CONTENT_STREAM_KEY = "post-content-ingestion"
@@ -33,6 +34,15 @@ class PostContentJobRequest:
should_publish: bool
+@dataclass(frozen=True)
+class PostContentRecoveryPage:
+ """One fair recovery page and the keyset needed for the next page."""
+
+ published_count: int
+ next_eligible_at: datetime | None
+ next_post_id: str | None
+
+
def source_body_sha256(body: str) -> str:
"""Hash the immutable source representation, never the derived content."""
return hashlib.sha256(body.encode("utf-8")).hexdigest()
@@ -151,6 +161,17 @@ async def publish_post_content_event(
return str(entry_id)
+async def trim_post_content_events_through(client: redis.Redis, entry_id: str) -> None:
+ """Trim only wake-ups at or before the worker's consumed cursor."""
+ milliseconds, sequence = entry_id.split("-", 1)
+ exclusive_minimum = f"{int(milliseconds)}-{int(sequence) + 1}"
+ await client.xtrim(
+ POST_CONTENT_STREAM_KEY,
+ minid=exclusive_minimum,
+ approximate=False,
+ )
+
+
async def _record_status(
conn: asyncpg.Connection,
post_id: str,
@@ -189,6 +210,14 @@ async def transition_post_content_job(
failure_code: str | None = None,
detail_text: str | None = None,
expected_attempt_count: int | None = None,
+ channel_stage_code: str | None = None,
+ http_status: int | None = None,
+ orchestrator_error_code: str | None = None,
+ retryable: bool | None = None,
+ session_correlation_id: str | None = None,
+ failure_error_type: str | None = None,
+ failure_validation_code: str | None = None,
+ failure_validation_path: str | None = None,
) -> bool:
"""Update one job attempt and append its lifecycle event atomically.
@@ -207,9 +236,18 @@ async def transition_post_content_job(
end,
completed_at = case when $2 in ($4, $5) then now() else null end,
queued_at = case when $2 = $6 then now() else queued_at end,
+ next_attempt_at = null,
updated_at = now(),
last_error_code = $7,
- last_error_detail = $8
+ last_error_detail = $8,
+ failure_channel_stage_code = $10,
+ failure_http_status = $11,
+ failure_orchestrator_error_code = $12,
+ failure_retryable = $13,
+ failure_session_correlation_id = $14,
+ failure_error_type = $15,
+ failure_validation_code = $16,
+ failure_validation_path = $17
where post_id = $1
and ($9::integer is null or attempt_count = $9)
""",
@@ -222,6 +260,14 @@ async def transition_post_content_job(
failure_code,
detail_text,
expected_attempt_count,
+ channel_stage_code,
+ http_status,
+ orchestrator_error_code,
+ retryable,
+ session_correlation_id,
+ failure_error_type,
+ failure_validation_code,
+ failure_validation_path,
)
if not updated.endswith(" 1"):
return False
@@ -235,6 +281,61 @@ async def transition_post_content_job(
return True
+async def defer_post_content_job(
+ conn: asyncpg.Connection,
+ post_id: str,
+ *,
+ expected_attempt_count: int,
+ retry_after_seconds: int,
+) -> bool:
+ """Return one unadmitted lease to queued without consuming an attempt."""
+ if type(retry_after_seconds) is not int or retry_after_seconds <= 0:
+ raise ValueError("retry_after_seconds must be a positive integer")
+ updated = await conn.execute(
+ """
+ update post_content_ingestion_job
+ set status_code = $2,
+ attempt_count = attempt_count - 1,
+ queued_at = now(),
+ next_attempt_at = now() + make_interval(secs => $5),
+ started_at = null,
+ completed_at = null,
+ updated_at = now(),
+ last_error_code = $6,
+ last_error_detail = $7,
+ failure_channel_stage_code = null,
+ failure_http_status = null,
+ failure_orchestrator_error_code = null,
+ failure_retryable = null,
+ failure_session_correlation_id = null,
+ failure_error_type = null,
+ failure_validation_code = null,
+ failure_validation_path = null
+ where post_id = $1
+ and status_code = $3
+ and attempt_count = $4
+ and attempt_count > 0
+ """,
+ post_id,
+ QUEUED,
+ RUNNING,
+ expected_attempt_count,
+ retry_after_seconds,
+ "no_viable_agent",
+ "Analysis capacity is being restored; this record will retry automatically.",
+ )
+ if not updated.endswith(" 1"):
+ return False
+ await _record_status(
+ conn,
+ post_id,
+ QUEUED,
+ failure_code="no_viable_agent",
+ detail_text="Analysis capacity is being restored; this record will retry automatically.",
+ )
+ return True
+
+
async def ensure_post_content_job(
conn: asyncpg.Connection,
post_id: str,
@@ -286,6 +387,7 @@ async def ensure_post_content_job(
status_code = $3,
attempt_count = 0,
queued_at = now(),
+ next_attempt_at = null,
started_at = null,
completed_at = null,
updated_at = now(),
@@ -307,6 +409,231 @@ async def ensure_post_content_job(
)
+POST_CONTENT_BACKFILL_CANDIDATE_SQL = f"""
+ with candidate_id as materialized (
+ select post.post_id
+ from source_post post
+ left join post_content_ingestion_job job on job.post_id = post.post_id
+ where job.post_id is null
+ union all
+ select job.post_id
+ from post_content_ingestion_job job
+ where job.status_code = $1
+ )
+ select post.post_id, post.post_body
+ from candidate_id candidate
+ join source_post post on post.post_id = candidate.post_id
+ where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
+ and (
+ not exists (
+ select 1 from post_content_unit unit
+ where unit.post_id = post.post_id
+ )
+ or ($2::boolean and exists (
+ select 1
+ from post_content_unit unit
+ left join post_content_embedding embedding
+ on embedding.post_content_unit_id = unit.post_content_unit_id
+ where unit.post_id = post.post_id
+ and embedding.post_content_embedding_id is null
+ ))
+ or ($2::boolean and exists (
+ select 1
+ from post_content_unit unit
+ join post_content_image image
+ on image.post_content_unit_id = unit.post_content_unit_id
+ join post_content_image_region region
+ on region.post_content_image_id = image.post_content_image_id
+ left join post_content_image_region_embedding embedding
+ on embedding.post_content_image_region_id = region.post_content_image_region_id
+ where unit.post_id = post.post_id
+ and region.description_status_code = 'described'
+ and embedding.post_content_image_region_embedding_id is null
+ ))
+ or ($3::boolean and exists (
+ select 1
+ from post_content_unit unit
+ left join post_content_unit_structure structure
+ on structure.post_content_unit_id = unit.post_content_unit_id
+ where unit.post_id = post.post_id
+ and unit.unit_kind_code <> 'image'
+ and (
+ structure.post_content_unit_structure_id is null
+ or structure.decision_source_code = 'unresolved'
+ )
+ ))
+ or ($3::boolean and not exists (
+ select 1
+ from post_content_ingestion_job job
+ join operations_case_analysis analysis
+ on analysis.post_id = job.post_id
+ and analysis.source_body_sha256 = job.source_body_sha256
+ where job.post_id = post.post_id
+ ))
+ or ($3::boolean and not exists (
+ select 1
+ from post_content_ingestion_job job
+ join post_product_analysis product_analysis
+ on product_analysis.post_id = job.post_id
+ and product_analysis.source_body_sha256 = job.source_body_sha256
+ and product_analysis.orchestrator_model_receipt is not null
+ where job.post_id = post.post_id
+ ))
+ or ($3::boolean and not exists (
+ select 1
+ from post_content_ingestion_job job
+ join post_voice_classification_analysis voice_analysis
+ on voice_analysis.post_id = job.post_id
+ and voice_analysis.source_body_sha256 = job.source_body_sha256
+ where job.post_id = post.post_id
+ ))
+ )
+ and ($5::boolean = (
+ $3::boolean
+ and exists (
+ select 1
+ from post_project_mention project
+ where project.post_id = post.post_id
+ and nullif(btrim(project.ontology_iri), '') is not null
+ )
+ and exists (
+ select 1
+ from post_content_ingestion_job job
+ where job.post_id = post.post_id
+ and job.source_body_sha256 is not null
+ )
+ and not exists (
+ select 1
+ from post_content_ingestion_job job
+ join operations_case_analysis analysis
+ on analysis.post_id = job.post_id
+ and analysis.source_body_sha256 = job.source_body_sha256
+ where job.post_id = post.post_id
+ )
+ ))
+ order by coalesce(post.event_occurred_at, post.created_at),
+ post.created_at,
+ post.post_id
+ limit $4
+ for update of post skip locked
+ """
+
+
+async def enqueue_post_content_backfill(
+ pool: asyncpg.Pool,
+ client: redis.Redis | None,
+ *,
+ limit: int,
+ require_embedding: bool,
+ require_structure: bool,
+) -> dict[str, int]:
+ """Durably enqueue one bounded page of eligible incomplete source posts.
+
+ PostgreSQL is committed before Valkey is touched. A missing wake-up is
+ therefore recoverable by :func:`republish_queued_post_content_jobs` rather
+ than turning an operator request into lost work. Active and terminal jobs
+ are excluded so repeated requests neither duplicate work nor reset the
+ explicit retry boundary.
+ """
+ if not 1 <= limit <= 200:
+ raise ValueError("limit must be between 1 and 200")
+ requests: list[PostContentJobRequest] = []
+ async with pool.acquire() as conn:
+ async with conn.transaction():
+ rows = []
+ recovery_state = await conn.fetchrow(
+ "select active_source_count, active_job_count, "
+ "active_succeeded_job_count, context_source_count, "
+ "context_job_count, context_succeeded_job_count "
+ "from post_content_recovery_state where recovery_state_id = 1"
+ )
+ recovery_is_empty = False
+ if recovery_state:
+ source_prefix = (
+ "context" if recovery_state["context_source_count"] else "active"
+ )
+ recovery_is_empty = bool(
+ recovery_state[f"{source_prefix}_source_count"]
+ == recovery_state[f"{source_prefix}_job_count"]
+ and recovery_state[f"{source_prefix}_succeeded_job_count"] == 0
+ )
+ if require_structure and not recovery_is_empty:
+ # Safe SQL: the eligibility predicate is an immutable schema fragment; values are bound.
+ rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ POST_CONTENT_BACKFILL_CANDIDATE_SQL,
+ SUCCEEDED,
+ require_embedding,
+ require_structure,
+ limit,
+ True,
+ )
+ if not recovery_is_empty and len(rows) < limit:
+ # Safe SQL: the same immutable candidate statement is reused with bound tier values.
+ rows += await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ POST_CONTENT_BACKFILL_CANDIDATE_SQL,
+ SUCCEEDED,
+ require_embedding,
+ require_structure,
+ limit - len(rows),
+ False,
+ )
+ unique_rows = []
+ seen_post_ids: set[str] = set()
+ for row in rows:
+ post_id = str(row["post_id"])
+ if post_id in seen_post_ids:
+ continue
+ seen_post_ids.add(post_id)
+ unique_rows.append(row)
+ rows = unique_rows
+ for row in rows:
+ post_id = str(row["post_id"])
+ body = str(row["post_body"] or "")
+ complete = await post_content_is_complete(
+ conn,
+ post_id,
+ require_embedding=require_embedding,
+ require_structure=require_structure,
+ )
+ if complete and require_structure:
+ complete = bool(
+ await conn.fetchval(
+ "select exists (select 1 from operations_case_analysis "
+ "where post_id = $1 and source_body_sha256 = $2) "
+ "and exists (select 1 from post_product_analysis "
+ "where post_id = $1 and source_body_sha256 = $2 "
+ "and orchestrator_model_receipt is not null) "
+ "and exists (select 1 from post_voice_classification_analysis "
+ "where post_id = $1 and source_body_sha256 = $2)",
+ post_id,
+ source_body_sha256(body),
+ )
+ )
+ request = await ensure_post_content_job(
+ conn,
+ post_id,
+ body,
+ content_complete=complete,
+ )
+ if request.should_publish:
+ requests.append(request)
+
+ published = 0
+ for request in requests:
+ if await publish_post_content_event(
+ client,
+ post_id=request.post_id,
+ source_body_digest=request.source_body_sha256,
+ ):
+ published += 1
+ return {
+ "selected_posts": len(rows),
+ "queued_posts": len(requests),
+ "published_events": published,
+ "recovery_pending": len(requests) - published,
+ }
+
+
async def requeue_failed_post_content_job(
conn: asyncpg.Connection,
post_id: str,
@@ -334,6 +661,7 @@ async def requeue_failed_post_content_job(
status_code = $3,
attempt_count = 0,
queued_at = now(),
+ next_attempt_at = null,
started_at = null,
completed_at = null,
updated_at = now(),
@@ -356,6 +684,63 @@ async def requeue_failed_post_content_job(
return PostContentJobRequest(post_id, digest, QUEUED, True)
+async def requeue_failed_post_content_jobs(
+ pool: asyncpg.Pool,
+ client: redis.Redis | None,
+ *,
+ limit: int,
+) -> dict[str, int]:
+ """Requeue one bounded, ledger-backed page of terminal jobs.
+
+ The operator explicitly chooses this recovery path. PostgreSQL commits the
+ reset before Valkey wake-ups are published, so a transport failure remains
+ recoverable from the durable ``queued`` rows.
+ """
+ if not 1 <= limit <= 200:
+ raise ValueError("limit must be between 1 and 200")
+ requests: list[PostContentJobRequest] = []
+ async with pool.acquire() as conn:
+ async with conn.transaction():
+ # Safe SQL: the eligibility predicate is an immutable schema fragment; values are bound.
+ rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ f"""
+ select post.post_id, post.post_body
+ from post_content_ingestion_job job
+ join source_post post on post.post_id = job.post_id
+ where job.status_code = $1
+ and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
+ order by job.updated_at, post.post_id
+ limit $2
+ for update of job skip locked
+ """,
+ FAILED,
+ limit,
+ )
+ for row in rows:
+ requests.append(
+ await requeue_failed_post_content_job(
+ conn,
+ str(row["post_id"]),
+ str(row["post_body"] or ""),
+ )
+ )
+
+ published = 0
+ for request in requests:
+ if await publish_post_content_event(
+ client,
+ post_id=request.post_id,
+ source_body_digest=request.source_body_sha256,
+ ):
+ published += 1
+ return {
+ "selected_posts": len(requests),
+ "queued_posts": len(requests),
+ "published_events": published,
+ "recovery_pending": len(requests) - published,
+ }
+
+
async def record_post_content_backfill_success(
conn: asyncpg.Connection,
post_id: str,
@@ -393,6 +778,7 @@ async def record_post_content_backfill_success(
status_code = $3,
started_at = null,
completed_at = now(),
+ next_attempt_at = null,
updated_at = now(),
last_error_code = null,
last_error_detail = null
@@ -416,35 +802,58 @@ async def republish_queued_post_content_jobs(
pool: asyncpg.Pool,
*,
limit: int = 100,
-) -> int:
- """Recover queued rows and stale running leases when Valkey lost wake-ups."""
- async with pool.acquire() as conn:
- rows = await conn.fetch(
+ after_eligible_at: datetime | None = None,
+ after_post_id: str | None = None,
+) -> PostContentRecoveryPage:
+ """Republish one keyset page without starving rows beyond the first page."""
+ if (after_eligible_at is None) != (after_post_id is None):
+ raise ValueError("recovery keyset requires both eligible_at and post_id")
+
+ async def _fetch_page(
+ conn: asyncpg.Connection,
+ cursor_at: datetime | None,
+ cursor_id: str | None,
+ ) -> list[asyncpg.Record]:
+ return await conn.fetch(
"""
- select post_id, source_body_sha256
- from post_content_ingestion_job
- where (
- status_code = $1
- and (
- attempt_count = 0
- or queued_at <= now() - $2::interval
- )
+ with recovery_candidate as (
+ select post_id,
+ source_body_sha256,
+ case
+ when status_code = $1 then
+ case
+ when next_attempt_at is not null then next_attempt_at
+ when attempt_count = 0 then queued_at
+ else queued_at + $2::interval
+ end
+ when status_code = $3 and started_at is not null then
+ started_at + $4::interval
+ end as eligible_at
+ from post_content_ingestion_job
+ where status_code in ($1, $3)
)
- or (
- status_code = $3
- and started_at is not null
- and started_at < now() - $4::interval
- )
- order by queued_at
- limit $5
+ select post_id, source_body_sha256, eligible_at
+ from recovery_candidate
+ where eligible_at <= now()
+ and ($5::timestamptz is null or (eligible_at, post_id) > ($5, $6::uuid))
+ order by eligible_at, post_id
+ limit $7
""",
QUEUED,
POST_CONTENT_RETRY_INTERVAL,
RUNNING,
STALE_RUNNING_INTERVAL,
+ cursor_at,
+ cursor_id,
limit,
)
+
+ async with pool.acquire() as conn:
+ rows = await _fetch_page(conn, after_eligible_at, after_post_id)
+ if not rows and after_eligible_at is not None:
+ rows = await _fetch_page(conn, None, None)
published = 0
+ last_published_row: asyncpg.Record | None = None
for row in rows:
if await publish_post_content_event(
client,
@@ -452,7 +861,20 @@ async def republish_queued_post_content_jobs(
source_body_digest=str(row["source_body_sha256"]),
):
published += 1
- return published
+ last_published_row = row
+ else:
+ break
+ if last_published_row is None:
+ return PostContentRecoveryPage(
+ 0,
+ after_eligible_at,
+ after_post_id,
+ )
+ return PostContentRecoveryPage(
+ published,
+ last_published_row["eligible_at"],
+ str(last_published_row["post_id"]),
+ )
def serialize_job_row(row: Any) -> dict[str, Any]:
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 97cde8dcd..7b264f55b 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -4,8 +4,8 @@
import asyncio
import logging
-import time
from collections.abc import Callable
+from datetime import datetime
from uuid import UUID
import asyncpg
@@ -21,8 +21,12 @@
RUNNING,
STALE_RUNNING_INTERVAL,
SUCCEEDED,
+ defer_post_content_job,
+ enqueue_post_content_backfill,
+ ensure_post_content_job,
post_content_is_complete,
republish_queued_post_content_jobs,
+ trim_post_content_events_through,
transition_post_content_job,
)
from backend.app.operations_case_ingestion import persist_operations_cases
@@ -30,15 +34,27 @@
extract_occupational_construct_assertions,
persist_occupational_construct_assertions,
)
-from backend.app.post_chat_ingestion import gather_chat_sources
+from backend.app.product_semantic_ingestion import (
+ load_current_product_relation_targets,
+ persist_product_mentions,
+ resolve_product_mentions,
+)
+from backend.app.post_chat_ingestion import (
+ find_project_sibling_post_ids,
+ gather_chat_sources,
+)
+from backend.app.voice_classification_ingestion import (
+ persist_derived_voice_classification,
+)
from lineageweave.embedding_client import EmbeddingClient
-from lineageweave.http_client import HttpClientError
+from lineageweave.http_client import HttpAdmissionDeferred, HttpClientError
from lineageweave.image_content import ImageContentClient
from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata
from lineageweave.observability import record_server_failure, traced
from lineageweave.operations_case_analysis import (
ContextualOrchestratorOperationsCaseAnalysisClient,
OperationsEvidenceSource,
+ operations_analysis_input_sha256,
)
from lineageweave.occupational_construct_extraction import (
ContextualOrchestratorOccupationalConstructExtractionClient,
@@ -46,14 +62,43 @@
from lineageweave.post_content_normalization import normalize_post_body
from lineageweave.post_content_persistence import persist_post_content
from lineageweave.post_structure import PostStructureClient
+from lineageweave.product_semantics import (
+ ContextualOrchestratorProductExtractionClient,
+ ProductEvidenceSource,
+ product_analysis_input_sha256,
+)
+from lineageweave.voice_classification import (
+ ContextualOrchestratorVoiceClassificationClient,
+)
_logger = logging.getLogger(__name__)
_RECOVERY_INTERVAL_SECONDS = 30.0
+_RECOVERY_ENQUEUE_LIMIT = 200
_BROKER_RECOVERY_DELAY_SECONDS = 1.0
+_STREAM_BATCH_SIZE = 10
_INCOMPLETE_FAILURE_CODE = "post_content_ingestion_incomplete"
_ATTEMPT_LIMIT_FAILURE_CODE = "post_content_ingestion_attempt_limit"
_SOURCE_BODY_MISSING_FAILURE_CODE = "post_content_source_body_missing"
-_UNEXPECTED_FAILURE_DETAIL = "post-content provider operation failed; retry the ingestion job"
+_UNEXPECTED_FAILURE_DETAIL = (
+ "post-content provider operation failed; retry the ingestion job"
+)
+
+
+def _bounded_failure_error_type(error: Exception | None) -> str | None:
+ """Map exceptions to a closed operational taxonomy without module or message."""
+ if error is None:
+ return None
+ for exception_type, code in (
+ (HttpClientError, "http_client_error"),
+ (TimeoutError, "timeout_error"),
+ (KeyError, "key_error"),
+ (OSError, "os_error"),
+ (ValueError, "value_error"),
+ (RuntimeError, "runtime_error"),
+ ):
+ if isinstance(error, exception_type):
+ return code
+ return "internal_error"
async def _operations_evidence_sources(
@@ -75,6 +120,25 @@ def can_see(row: asyncpg.Record) -> bool:
async with pool.acquire() as conn:
sources = await gather_chat_sources(conn, post_id, can_see, vision_client)
+ if not sources:
+ return ()
+ source_post_ids = [UUID(source.post_id) for source in sources]
+ source_times = {
+ str(row["post_id"]): (
+ row["observed_at"],
+ "event_occurred_at"
+ if row["event_occurred_at"] is not None
+ else "created_at",
+ )
+ for row in await conn.fetch(
+ "select post_id, event_occurred_at, "
+ "coalesce(event_occurred_at, created_at) as observed_at "
+ "from source_post where post_id = any($1::uuid[])",
+ source_post_ids,
+ )
+ }
+ if any(source.post_id not in source_times for source in sources):
+ raise RuntimeError("authorized evidence source clock unavailable")
return tuple(
OperationsEvidenceSource(
source.post_id,
@@ -85,11 +149,198 @@ def can_see(row: asyncpg.Record) -> bool:
if source.evidence_facts
else ""
),
+ source_times[source.post_id][0],
+ source_times[source.post_id][1],
+ source.post_body,
)
for source in sources
)
+async def _persist_operations_case_analysis_if_needed(
+ pool: asyncpg.Pool,
+ post_id: str,
+ source_body_digest: str,
+ raw_body: str,
+ row: asyncpg.Record,
+ vision_client: ImageContentClient,
+ session_id: str,
+ orchestrator_base_url: str,
+ orchestrator_api_key: str,
+ evidence_sources: tuple[OperationsEvidenceSource, ...] | None = None,
+) -> None:
+ """Persist cases once per exact focal body and authorized evidence window."""
+ context = _operations_analysis_context(row)
+ if evidence_sources is None:
+ evidence_sources = await _operations_evidence_sources(
+ pool, post_id, row, vision_client
+ )
+ analysis_input_digest = operations_analysis_input_sha256(
+ evidence_sources, context
+ )
+ async with pool.acquire() as conn:
+ already_persisted = bool(
+ await conn.fetchval(
+ "select exists (select 1 from operations_case_analysis "
+ "where post_id = $1 and source_body_sha256 = $2 "
+ "and analysis_input_sha256 = $3)",
+ post_id,
+ source_body_digest,
+ analysis_input_digest,
+ )
+ )
+ if already_persisted:
+ return
+ case_client = ContextualOrchestratorOperationsCaseAnalysisClient(
+ orchestrator_base_url,
+ orchestrator_api_key,
+ )
+ cases = await asyncio.to_thread(case_client.analyze, evidence_sources, context)
+ async with pool.acquire() as conn:
+ await persist_operations_cases(
+ conn,
+ post_id,
+ raw_body,
+ session_id,
+ cases,
+ analysis_input_sha256=analysis_input_digest,
+ )
+
+
+def _operations_analysis_context(row: asyncpg.Record) -> str:
+ """Build the bounded source context shared by digest and case analysis."""
+ return " | ".join(
+ f"{name}={row[name]}"
+ for name in (
+ "source_project_code",
+ "source_project_name",
+ "source_sales_pool_code",
+ "source_sales_pool_name",
+ "voc_type_code",
+ )
+ if row.get(name) is not None and str(row[name]).strip()
+ )
+
+
+async def _persist_product_analysis_if_needed(
+ pool: asyncpg.Pool,
+ post_id: str,
+ source_body_digest: str,
+ raw_body: str,
+ session_id: str,
+ orchestrator_base_url: str,
+ orchestrator_api_key: str,
+ expected_operations_input_sha256: str | None,
+) -> None:
+ """Persist focal products independently using only current typed targets."""
+ sources = (ProductEvidenceSource(post_id, raw_body),)
+ async with pool.acquire() as conn:
+ targets = await load_current_product_relation_targets(
+ conn,
+ post_id,
+ source_body_digest,
+ expected_operations_input_sha256,
+ )
+ input_digest = product_analysis_input_sha256(sources, targets)
+ async with pool.acquire() as conn:
+ already_persisted = bool(
+ await conn.fetchval(
+ "select exists (select 1 from post_product_analysis "
+ "where post_id = $1 and source_body_sha256 = $2 "
+ "and analysis_input_sha256 = $3 "
+ "and orchestrator_model_receipt is not null)",
+ post_id,
+ source_body_digest,
+ input_digest,
+ )
+ )
+ if already_persisted:
+ return
+ client = ContextualOrchestratorProductExtractionClient(
+ orchestrator_base_url, orchestrator_api_key
+ )
+ result = await asyncio.to_thread(
+ client.extract, sources, targets, session_id=session_id
+ )
+ async with pool.acquire() as conn:
+ resolved = await resolve_product_mentions(conn, result.extraction.mentions)
+ await persist_product_mentions(
+ conn,
+ post_id,
+ input_digest,
+ session_id,
+ resolved,
+ result,
+ expected_operations_input_sha256=expected_operations_input_sha256,
+ )
+
+
+async def _persist_voice_classification_if_needed(
+ pool: asyncpg.Pool,
+ post_id: str,
+ source_body_digest: str,
+ raw_body: str,
+ orchestrator_base_url: str,
+ orchestrator_api_key: str,
+) -> None:
+ """Persist one strict derived Voice receipt for the exact focal body."""
+ async with pool.acquire() as conn:
+ already_persisted = bool(
+ await conn.fetchval(
+ "select exists (select 1 from post_voice_classification_analysis "
+ "where post_id = $1::uuid and source_body_sha256 = $2)",
+ post_id,
+ source_body_digest,
+ )
+ )
+ if already_persisted:
+ return
+ client = ContextualOrchestratorVoiceClassificationClient(
+ orchestrator_base_url, orchestrator_api_key
+ )
+ result = await asyncio.to_thread(client.classify, raw_body)
+ if result.source_revision_digest != source_body_digest:
+ raise ValueError("derived Voice result digest did not match the claimed source")
+ async with pool.acquire() as conn:
+ await persist_derived_voice_classification(conn, post_id, result)
+
+
+async def _requeue_project_missing_case_jobs(
+ pool: asyncpg.Pool,
+ post_id: str,
+) -> int:
+ """Re-analyze older project siblings that still lack required facts."""
+ async with pool.acquire() as conn:
+ async with conn.transaction():
+ sibling_ids = await find_project_sibling_post_ids(conn, post_id)
+ if not sibling_ids:
+ return 0
+ rows = await conn.fetch(
+ """
+ select distinct post.post_id, post.post_body
+ from operations_case_missing_fact missing
+ join source_post post on post.post_id = missing.post_id
+ join post_content_ingestion_job job on job.post_id = missing.post_id
+ where missing.post_id = any($1::uuid[])
+ and job.status_code = $2
+ and nullif(btrim(post.post_body), '') is not null
+ order by post.post_id
+ """,
+ [UUID(sibling_id) for sibling_id in sibling_ids],
+ SUCCEEDED,
+ )
+ queued = 0
+ for row in rows:
+ request = await ensure_post_content_job(
+ conn,
+ str(row["post_id"]),
+ str(row["post_body"]),
+ content_complete=False,
+ )
+ queued += int(request.should_publish)
+ return queued
+
+
async def _stream_tail(client: redis.Redis) -> str:
"""Start after historical wake-ups; the normalized ledger drives recovery."""
with traced(
@@ -120,7 +371,24 @@ async def _claim_job(
j.status_code as job_status_code,
j.attempt_count as job_attempt_count,
j.started_at as job_started_at,
- j.queued_at as job_queued_at
+ j.queued_at as job_queued_at,
+ j.next_attempt_at as job_next_attempt_at,
+ (
+ select analysis.source_body_sha256
+ from operations_case_analysis analysis
+ where analysis.post_id = p.post_id
+ ) as case_analysis_source_body_sha256,
+ (
+ select analysis.source_body_sha256
+ from post_product_analysis analysis
+ where analysis.post_id = p.post_id
+ and analysis.orchestrator_model_receipt is not null
+ ) as product_analysis_source_body_sha256,
+ (
+ select analysis.source_body_sha256
+ from post_voice_classification_analysis analysis
+ where analysis.post_id = p.post_id
+ ) as voice_analysis_source_body_sha256
from post_content_ingestion_job j
join source_post p on p.post_id = j.post_id
where j.post_id = $1::uuid
@@ -154,9 +422,16 @@ async def _claim_job(
detail_text="post-content ingestion attempt limit was already reached",
)
return None
- if status_code == QUEUED and attempt_count > 0:
+ if status_code == QUEUED and row["job_next_attempt_at"] is not None:
+ retry_ready = await conn.fetchval(
+ "select now() >= $1::timestamptz",
+ row["job_next_attempt_at"],
+ )
+ if not retry_ready:
+ return None
+ elif status_code == QUEUED and attempt_count > 0:
retry_ready = await conn.fetchval(
- "select now() >= $1 + $2::interval",
+ "select now() >= $1::timestamptz + $2::interval",
row["job_queued_at"],
POST_CONTENT_RETRY_INTERVAL,
)
@@ -185,11 +460,25 @@ async def _claim_job(
source_body_digest,
)
)
- if content_complete and case_complete and construct_complete:
+ if (
+ content_complete
+ and case_complete
+ and construct_complete
+ and (
+ not require_structure
+ or row["product_analysis_source_body_sha256"]
+ == source_body_digest
+ )
+ and (
+ not require_structure
+ or row.get("voice_analysis_source_body_sha256")
+ == source_body_digest
+ )
+ ):
return None
if status_code == RUNNING and row["job_started_at"] is not None:
stale = await conn.fetchval(
- "select now() - $1 > $2::interval",
+ "select now() - $1::timestamptz > $2::interval",
row["job_started_at"],
STALE_RUNNING_INTERVAL,
)
@@ -236,6 +525,9 @@ async def _finish_failed_job(
failure_code: str,
detail_text: str,
expected_attempt_count: int,
+ channel_stage_code: str | None = None,
+ error: Exception | None = None,
+ session_correlation_id: str | None = None,
) -> None:
"""Schedule one retry, or persist a terminal failure for this attempt.
@@ -273,6 +565,14 @@ async def _finish_failed_job(
else detail_text
),
expected_attempt_count=expected_attempt_count,
+ channel_stage_code=channel_stage_code,
+ http_status=getattr(error, "http_status", None),
+ orchestrator_error_code=getattr(error, "remote_error_code", None),
+ retryable=getattr(error, "retryable", None),
+ session_correlation_id=session_correlation_id,
+ failure_error_type=_bounded_failure_error_type(error),
+ failure_validation_code=getattr(error, "validation_code", None),
+ failure_validation_path=getattr(error, "validation_path", None),
)
@@ -321,56 +621,99 @@ async def process_post_content_job(
expected_attempt_count=attempt_count,
)
return
+ channel_stage_code = "metadata"
+ metadata: dict[str, str] = {}
try:
metadata = build_post_llm_metadata(post_id, row)
+ channel_stage_code = "client_initialization"
embedding_client = embedding_factory()
structure_client = structure_factory()
with use_llm_metadata(metadata):
vision_client = vision_factory()
- normalized = await asyncio.to_thread(normalize_post_body, raw_body, vision_client)
- async with pool.acquire() as conn:
- await persist_post_content(
- conn,
- post_id,
- raw_body,
- vision_client=vision_client,
- embedding_client=embedding_client,
- normalized_result=normalized,
- structure_client=structure_client,
- post_title=str(row["post_title"]),
- )
+ stage_failures: list[tuple[str, Exception]] = []
if settings.orchestrator_base_url and settings.orchestrator_api_key:
- case_client = ContextualOrchestratorOperationsCaseAnalysisClient(
- settings.orchestrator_base_url,
- settings.orchestrator_api_key,
- )
- context = " | ".join(
- f"{name}={row[name]}"
- for name in (
- "source_project_code",
- "source_project_name",
- "source_sales_pool_code",
- "source_sales_pool_name",
- "voc_type_code",
+ channel_stage_code = "voice_classification"
+ try:
+ await _persist_voice_classification_if_needed(
+ pool,
+ post_id,
+ source_body_digest,
+ raw_body,
+ settings.orchestrator_base_url,
+ settings.orchestrator_api_key,
)
- if row.get(name) is not None and str(row[name]).strip()
- )
- evidence_sources = await _operations_evidence_sources(
- pool, post_id, row, vision_client
- )
- cases = await asyncio.to_thread(
- case_client.analyze,
- evidence_sources,
- context,
- )
- async with pool.acquire() as conn:
- await persist_operations_cases(
- conn,
+ except HttpAdmissionDeferred as exc:
+ stage_failures.append(("voice_classification", exc))
+ except (HttpClientError, OSError, RuntimeError, TimeoutError, ValueError) as exc:
+ stage_failures.append(("voice_classification", exc))
+ _logger.error("derived Voice ingestion failed for post_id=%s", post_id)
+ record_server_failure(
+ "voice_classification_ingestion",
+ exc,
+ outcome="provider_unavailable",
+ )
+ expected_operations_input_sha256: str | None = None
+ try:
+ channel_stage_code = "operations_evidence"
+ evidence_sources = await _operations_evidence_sources(
+ pool, post_id, row, vision_client
+ )
+ expected_operations_input_sha256 = operations_analysis_input_sha256(
+ evidence_sources, _operations_analysis_context(row)
+ )
+ channel_stage_code = "operations_case"
+ await _persist_operations_case_analysis_if_needed(
+ pool,
+ post_id,
+ source_body_digest,
+ raw_body,
+ row,
+ vision_client,
+ metadata["lineageweave_post_session_id"],
+ settings.orchestrator_base_url,
+ settings.orchestrator_api_key,
+ evidence_sources,
+ )
+ except HttpAdmissionDeferred as exc:
+ stage_failures.append((channel_stage_code, exc))
+ except (
+ HttpClientError,
+ KeyError,
+ OSError,
+ RuntimeError,
+ TimeoutError,
+ ValueError,
+ ) as exc:
+ stage_failures.append((channel_stage_code, exc))
+ _logger.error("operations evidence ingestion failed for post_id=%s", post_id)
+ record_server_failure(
+ "operations_case_ingestion",
+ exc,
+ outcome="provider_unavailable",
+ )
+ channel_stage_code = "product_analysis"
+ try:
+ await _persist_product_analysis_if_needed(
+ pool,
post_id,
+ source_body_digest,
raw_body,
metadata["lineageweave_post_session_id"],
- cases,
+ settings.orchestrator_base_url,
+ settings.orchestrator_api_key,
+ expected_operations_input_sha256,
)
+ except HttpAdmissionDeferred as exc:
+ stage_failures.append(("product_analysis", exc))
+ except (HttpClientError, OSError, RuntimeError, TimeoutError, ValueError) as exc:
+ stage_failures.append(("product_analysis", exc))
+ _logger.error("product evidence ingestion failed for post_id=%s", post_id)
+ record_server_failure(
+ "product_semantic_ingestion",
+ exc,
+ outcome="provider_unavailable",
+ )
+ channel_stage_code = "occupational_construct"
construct_client = (
ContextualOrchestratorOccupationalConstructExtractionClient(
settings.orchestrator_base_url,
@@ -388,11 +731,40 @@ async def process_post_content_job(
assertions,
source_body_sha256=source_body_digest,
)
+ channel_stage_code = "content_normalization"
+ normalized = await asyncio.to_thread(
+ normalize_post_body, raw_body, vision_client
+ )
+ channel_stage_code = "content_persistence"
+ async with pool.acquire() as conn:
+ await persist_post_content(
+ conn,
+ post_id,
+ raw_body,
+ vision_client=vision_client,
+ embedding_client=embedding_client,
+ normalized_result=normalized,
+ structure_client=structure_client,
+ post_title=str(row["post_title"]),
+ )
+ if stage_failures:
+ selected_stage, selected_failure = next(
+ (
+ (stage, failure)
+ for stage, failure in stage_failures
+ if not isinstance(failure, HttpAdmissionDeferred)
+ ),
+ stage_failures[0],
+ )
+ channel_stage_code = selected_stage
+ raise selected_failure
async with pool.acquire() as conn:
complete = await post_content_is_complete(
conn,
post_id,
- embedding_model_code=getattr(embedding_client, "resolved_model", None),
+ embedding_model_code=getattr(
+ embedding_client, "resolved_model", None
+ ),
require_embedding=require_orchestrator_evidence,
require_structure=require_orchestrator_evidence,
)
@@ -403,8 +775,37 @@ async def process_post_content_job(
failure_code=_INCOMPLETE_FAILURE_CODE,
detail_text="post-content providers did not produce complete persisted evidence",
expected_attempt_count=attempt_count,
+ channel_stage_code=channel_stage_code,
+ session_correlation_id=metadata.get(
+ "lineageweave_post_session_id"
+ ),
)
return
+ if (
+ settings.orchestrator_base_url
+ and settings.orchestrator_api_key
+ and row.get("case_analysis_source_body_sha256")
+ != source_body_digest
+ ):
+ try:
+ await _requeue_project_missing_case_jobs(pool, post_id)
+ except Exception as exc: # noqa: BLE001 - primary evidence is complete.
+ _logger.error("project sibling requeue failed for post_id=%s", post_id)
+ record_server_failure(
+ "post_content_sibling_requeue",
+ exc,
+ outcome="provider_unavailable",
+ )
+ except HttpAdmissionDeferred as exc:
+ async with pool.acquire() as conn:
+ async with conn.transaction():
+ await defer_post_content_job(
+ conn,
+ post_id,
+ expected_attempt_count=attempt_count,
+ retry_after_seconds=exc.retry_after_seconds,
+ )
+ return
except Exception as exc: # noqa: BLE001 - durable failure is recorded for retry.
_logger.error("post content ingestion failed for post_id=%s", post_id)
outcome = (
@@ -421,28 +822,27 @@ async def process_post_content_job(
failure_code="post_content_ingestion_failed",
detail_text=_UNEXPECTED_FAILURE_DETAIL,
expected_attempt_count=attempt_count,
+ channel_stage_code=channel_stage_code,
+ error=exc,
+ session_correlation_id=metadata.get("lineageweave_post_session_id"),
)
return
await _finish_job(pool, post_id, SUCCEEDED, expected_attempt_count=attempt_count)
-async def consume_post_content_stream_once(
+async def _read_post_content_stream_once(
client: redis.Redis,
- pool: asyncpg.Pool,
*,
last_id: str,
- vision_factory: Callable[[], ImageContentClient],
- embedding_factory: Callable[[], EmbeddingClient],
- structure_factory: Callable[[], PostStructureClient],
-) -> str:
- """Process one batch of the Valkey wake-up stream and return the new cursor.
-
- Reads up to 10 entries after `last_id`, runs `process_post_content_job`
- for each, and returns the last-seen entry id so the caller can resume
- from there on the next poll.
- """
+) -> tuple[str, list[tuple[str, str, str]]]:
+ """Read and validate one bounded wake-up batch without running providers."""
+ work: list[tuple[str, str, str]] = []
try:
- batches = await client.xread({POST_CONTENT_STREAM_KEY: last_id}, count=10, block=1000)
+ batches = await client.xread(
+ {POST_CONTENT_STREAM_KEY: last_id},
+ count=_STREAM_BATCH_SIZE,
+ block=1000,
+ )
except Exception:
# Keep idle polls silent, but retain a diagnostic span for broker failures.
with traced(
@@ -455,7 +855,7 @@ async def consume_post_content_stream_once(
):
raise
if not batches:
- return last_id
+ return last_id, work
with traced(
"lineageweave.valkey.post_content_batch",
{
@@ -472,46 +872,186 @@ async def consume_post_content_stream_once(
UUID(post_id)
except ValueError:
post_id = ""
- if post_id and len(digest) == 64:
- await process_post_content_job(
- pool,
- post_id=post_id,
- source_body_digest=digest,
- vision_factory=vision_factory,
- embedding_factory=embedding_factory,
- structure_factory=structure_factory,
- )
last_id = str(entry_id)
- return last_id
+ work.append((last_id, post_id, digest))
+ return last_id, work
-async def run_post_content_worker(
+async def _recover_post_content_jobs(
+ client: redis.Redis,
+ pool: asyncpg.Pool,
+ recovery_cursor: tuple[datetime, str] | None = None,
+) -> tuple[datetime, str] | None:
+ """Persist the next bounded candidate page and republish queued wake-ups."""
+ settings = load_settings()
+ require_orchestrator_evidence = bool(
+ settings.orchestrator_base_url and settings.orchestrator_api_key
+ )
+ try:
+ await enqueue_post_content_backfill(
+ pool,
+ client,
+ limit=_RECOVERY_ENQUEUE_LIMIT,
+ require_embedding=require_orchestrator_evidence,
+ require_structure=require_orchestrator_evidence,
+ )
+ except Exception as exc: # noqa: BLE001 - the next recovery cycle must remain alive.
+ _logger.warning(
+ "post-content candidate recovery failed; retrying next cycle (error_type=%s)",
+ type(exc).__name__,
+ )
+ record_server_failure(
+ "post_content_candidate_recovery",
+ exc,
+ outcome="provider_unavailable",
+ )
+ try:
+ page = await republish_queued_post_content_jobs(
+ client,
+ pool,
+ after_eligible_at=recovery_cursor[0] if recovery_cursor else None,
+ after_post_id=recovery_cursor[1] if recovery_cursor else None,
+ )
+ recovery_cursor = (
+ (page.next_eligible_at, page.next_post_id)
+ if page.next_eligible_at is not None and page.next_post_id is not None
+ else None
+ )
+ except Exception as exc: # noqa: BLE001 - broker recovery is independent of selection.
+ _logger.warning(
+ "post-content wake-up recovery failed; retrying next cycle (error_type=%s)",
+ type(exc).__name__,
+ )
+ record_server_failure(
+ "post_content_wakeup_recovery",
+ exc,
+ outcome="provider_unavailable",
+ )
+ return recovery_cursor
+
+
+async def consume_post_content_stream_once(
client: redis.Redis,
pool: asyncpg.Pool,
*,
+ last_id: str,
vision_factory: Callable[[], ImageContentClient],
embedding_factory: Callable[[], EmbeddingClient],
structure_factory: Callable[[], PostStructureClient],
-) -> None:
- """Run the at-least-once consumer and periodically recover queued rows."""
- last_id = await _stream_tail(client)
- last_recovery = 0.0
- while True:
- now = time.monotonic()
- if now - last_recovery >= _RECOVERY_INTERVAL_SECONDS:
- await republish_queued_post_content_jobs(client, pool)
- last_recovery = now
- try:
- last_id = await consume_post_content_stream_once(
- client,
+) -> str:
+ """Process one batch of the Valkey wake-up stream and return the new cursor.
+
+ Reads up to 10 entries after `last_id`, runs `process_post_content_job`
+ for each, and returns the last-seen entry id so the caller can resume
+ from there on the next poll.
+ """
+ last_id, work = await _read_post_content_stream_once(client, last_id=last_id)
+ for _entry_id, post_id, digest in work:
+ if post_id and len(digest) == 64:
+ await process_post_content_job(
pool,
- last_id=last_id,
+ post_id=post_id,
+ source_body_digest=digest,
vision_factory=vision_factory,
embedding_factory=embedding_factory,
structure_factory=structure_factory,
)
+ if work:
+ await trim_post_content_events_through(client, last_id)
+ return last_id
+
+
+async def _post_content_stream_reader(
+ client: redis.Redis,
+ work_queue: asyncio.Queue[tuple[str, str, str]],
+ *,
+ last_id: str,
+) -> None:
+ """Keep consuming wake-ups while the single provider processor is busy."""
+ while True:
+ try:
+ last_id, work = await _read_post_content_stream_once(client, last_id=last_id)
except (redis.RedisError, OSError) as exc:
_logger.warning(
- "post-content Valkey poll failed; retrying (error_type=%s)", type(exc).__name__
+ "post-content Valkey poll failed; retrying (error_type=%s)",
+ type(exc).__name__,
)
await asyncio.sleep(_BROKER_RECOVERY_DELAY_SECONDS)
+ continue
+ for item in work:
+ await work_queue.put(item)
+
+
+async def _post_content_processor(
+ pool: asyncpg.Pool,
+ client: redis.Redis,
+ work_queue: asyncio.Queue[tuple[str, str, str]],
+ *,
+ vision_factory: Callable[[], ImageContentClient],
+ embedding_factory: Callable[[], EmbeddingClient],
+ structure_factory: Callable[[], PostStructureClient],
+) -> None:
+ """Run exactly one provider pipeline while reader and recovery stay live."""
+ while True:
+ item = await work_queue.get()
+ try:
+ entry_id, post_id, digest = item
+ if post_id and len(digest) == 64:
+ await process_post_content_job(
+ pool,
+ post_id=post_id,
+ source_body_digest=digest,
+ vision_factory=vision_factory,
+ embedding_factory=embedding_factory,
+ structure_factory=structure_factory,
+ )
+ await trim_post_content_events_through(client, entry_id)
+ finally:
+ work_queue.task_done()
+
+
+async def _post_content_recovery_loop(client: redis.Redis, pool: asyncpg.Pool) -> None:
+ """Republish durable due work independently of provider latency."""
+ recovery_cursor: tuple[datetime, str] | None = None
+ while True:
+ recovery_cursor = await _recover_post_content_jobs(
+ client, pool, recovery_cursor
+ )
+ await asyncio.sleep(_RECOVERY_INTERVAL_SECONDS)
+
+
+async def run_post_content_worker(
+ client: redis.Redis,
+ pool: asyncpg.Pool,
+ *,
+ vision_factory: Callable[[], ImageContentClient],
+ embedding_factory: Callable[[], EmbeddingClient],
+ structure_factory: Callable[[], PostStructureClient],
+) -> None:
+ """Supervise one provider processor plus independent reader and recovery."""
+ last_id = await _stream_tail(client)
+ work_queue: asyncio.Queue[tuple[str, str, str]] = asyncio.Queue(
+ maxsize=_STREAM_BATCH_SIZE
+ )
+ tasks = (
+ asyncio.create_task(
+ _post_content_stream_reader(client, work_queue, last_id=last_id)
+ ),
+ asyncio.create_task(
+ _post_content_processor(
+ pool,
+ client,
+ work_queue,
+ vision_factory=vision_factory,
+ embedding_factory=embedding_factory,
+ structure_factory=structure_factory,
+ )
+ ),
+ asyncio.create_task(_post_content_recovery_loop(client, pool)),
+ )
+ try:
+ await asyncio.gather(*tasks)
+ finally:
+ for task in tasks:
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
diff --git a/backend/app/post_eligibility.py b/backend/app/post_eligibility.py
index 2dd7db2c2..5a4a31842 100644
--- a/backend/app/post_eligibility.py
+++ b/backend/app/post_eligibility.py
@@ -32,21 +32,27 @@ def source_context_missing_sql(alias: str) -> str:
)
-SOURCE_POST_ELIGIBILITY_SQL = (
- "nullif(btrim({alias}.source_draft_code), '') is null "
- "and nullif(btrim({alias}.source_deleted_flag), '') is null "
- "and not ("
- "({missing_context}) "
- "and exists ("
- "select 1 from source_post real_post "
- "where ({present_context})"
- ")"
- ")"
-).format(
- alias="{alias}",
- missing_context=source_context_missing_sql("{alias}"),
- present_context=source_context_present_sql("real_post"),
-)
+def source_post_eligibility_sql(
+ alias: str, *, source_context_required: bool | None = None
+) -> str:
+ """Return the exact publication predicate for a known or unknown corpus mode."""
+ active = (
+ f"({alias}.source_draft_code is null or btrim({alias}.source_draft_code) = '') "
+ f"and ({alias}.source_deleted_flag is null or btrim({alias}.source_deleted_flag) = '')"
+ )
+ if source_context_required is False:
+ return active
+ local_context = source_context_present_sql(alias)
+ if source_context_required is True:
+ return f"{active} and ({local_context})"
+ return (
+ f"{active} and (({local_context}) or not exists ("
+ "select 1 from source_post real_post "
+ f"where ({source_context_present_sql('real_post')})))"
+ )
+
+
+SOURCE_POST_ELIGIBILITY_SQL = source_post_eligibility_sql("{alias}")
def source_post_scope_sql(alias: str) -> str:
diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py
index 022d372c2..6853db9d0 100644
--- a/backend/app/post_summary_ingestion.py
+++ b/backend/app/post_summary_ingestion.py
@@ -279,6 +279,12 @@ async def persist_post_summary(
resolved_organization_ids[role_index] = corporate_entity_id
async with conn.transaction():
+ # Product extraction uses this same source-row lock while rechecking
+ # its project targets. Serialize target replacement before invalidation.
+ await conn.execute(
+ "select 1 from source_post where post_id = $1::uuid for update",
+ post_id,
+ )
await _replace_summary_projection(
conn,
post_id,
@@ -323,6 +329,10 @@ async def _replace_summary_projection(
"""Write one atomic replacement using pre-resolved shared identities."""
# Summary replacement owns only R&R projections. Keyman mentions remain
# independent and are combined only by the graph read/derivation view.
+ # Product-to-project evidence belongs to the exact normalized project
+ # target set. Invalidate it before replacing those targets so a deleted
+ # relation cannot be mistaken for an already-complete analysis.
+ await conn.execute("delete from post_product_analysis where post_id = $1", post_id)
await conn.execute(
"delete from post_summary_person_mention where post_id = $1",
post_id,
diff --git a/backend/app/product_catalog_provisioning.py b/backend/app/product_catalog_provisioning.py
new file mode 100644
index 000000000..b02615fe8
--- /dev/null
+++ b/backend/app/product_catalog_provisioning.py
@@ -0,0 +1,234 @@
+"""Provision product identities only from explicit governed source records."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from dataclasses import dataclass
+from typing import Any, Protocol
+
+from lineageweave.product_semantics import normalize_product_alias
+
+
+_PRODUCT_LEVEL_CODES = frozenset(
+ {"product_group", "product_model", "variant", "trade_item"}
+)
+
+
+class ProductCatalogProvisioningConflict(ValueError):
+ """An existing source or product identity contradicts the import row."""
+
+
+class ProductCatalogParentMissing(ValueError):
+ """The explicitly named parent product does not exist."""
+
+
+@dataclass(frozen=True)
+class ProductCatalogImport:
+ """One explicit product-master row and its source provenance."""
+
+ product_code: str
+ preferred_label: str
+ product_level_code: str
+ parent_product_code: str | None
+ aliases: tuple[str, ...]
+ corporate_entity_id: str
+ source_system_code: str
+ source_record_key: str
+
+ def normalized_aliases(self) -> tuple[tuple[str, str], ...]:
+ """Return unique explicit aliases, including the preferred label."""
+ values: dict[str, str] = {}
+ for alias in (self.preferred_label, *self.aliases):
+ if "\x00" in alias:
+ raise ValueError("product aliases must be valid PostgreSQL text")
+ normalized = normalize_product_alias(alias)
+ if not normalized:
+ raise ValueError("product aliases must not be blank")
+ prior = values.get(normalized)
+ if prior is not None and prior != alias.strip():
+ raise ValueError("two aliases normalize to the same catalog key")
+ values[normalized] = alias.strip()
+ return tuple(sorted(values.items()))
+
+ def source_payload_sha256(self) -> str:
+ """Digest the canonical row persisted by the import contract."""
+ payload = {
+ "aliases": self.normalized_aliases(),
+ "corporate_entity_id": self.corporate_entity_id,
+ "parent_product_code": (
+ self.parent_product_code.strip()
+ if self.parent_product_code is not None
+ else None
+ ),
+ "preferred_label": self.preferred_label.strip(),
+ "product_code": self.product_code.strip(),
+ "product_level_code": self.product_level_code,
+ "source_record_key": self.source_record_key.strip(),
+ "source_system_code": self.source_system_code,
+ }
+ encoded = json.dumps(
+ payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
+class _Connection(Protocol):
+ def transaction(self) -> Any:
+ """Open an atomic database transaction."""
+ pass
+
+ async def fetchrow(self, query: str, *args: object) -> Any:
+ """Fetch one row."""
+ pass
+
+ async def execute(self, query: str, *args: object) -> Any:
+ """Execute one parameterized statement."""
+ pass
+
+
+async def provision_product_catalog_entry(
+ conn: _Connection,
+ entry: ProductCatalogImport,
+ *,
+ imported_by_account_id: str,
+) -> dict[str, object]:
+ """Add one immutable source-bound product definition idempotently."""
+ for name, value in (
+ ("product code", entry.product_code),
+ ("preferred label", entry.preferred_label),
+ ("source record key", entry.source_record_key),
+ ):
+ if not value.strip() or "\x00" in value:
+ raise ValueError(f"{name} must be nonblank PostgreSQL text")
+ if not re.fullmatch(r"[a-z][a-z0-9_]{0,62}", entry.source_system_code):
+ raise ValueError("source system code is outside the governed vocabulary")
+ if entry.product_level_code not in _PRODUCT_LEVEL_CODES:
+ raise ValueError("product level code is outside the governed vocabulary")
+ if entry.parent_product_code is not None:
+ if not entry.parent_product_code.strip() or "\x00" in entry.parent_product_code:
+ raise ValueError("parent product code must be valid nonblank PostgreSQL text")
+ aliases = entry.normalized_aliases()
+ digest = entry.source_payload_sha256()
+ async with conn.transaction():
+ # Serialize this composite source key before reading it. PostgreSQL's
+ # 64-bit hash can only add harmless contention on a collision; the
+ # three-column primary key remains the identity and integrity owner.
+ await conn.execute(
+ "select pg_advisory_xact_lock(hashtextextended("
+ "jsonb_build_array($1::text, $2::text, $3::text)::text, 0))",
+ entry.corporate_entity_id,
+ entry.source_system_code,
+ entry.source_record_key.strip(),
+ )
+ source_row = await conn.fetchrow(
+ "select source.product_catalog_id, source.source_payload_sha256, "
+ "catalog.product_catalog_code from product_catalog_source_record source "
+ "join product_catalog catalog on catalog.product_catalog_id = source.product_catalog_id "
+ "where source.corporate_entity_id = $1::uuid and source.source_system_code = $2 "
+ "and source.source_record_key = $3 for update of source",
+ entry.corporate_entity_id,
+ entry.source_system_code,
+ entry.source_record_key.strip(),
+ )
+ if source_row is not None:
+ if (
+ source_row["product_catalog_code"] != entry.product_code.strip()
+ or source_row["source_payload_sha256"] != digest
+ ):
+ raise ProductCatalogProvisioningConflict(
+ "the governed source record already has a different product definition"
+ )
+ return {
+ "product_catalog_id": str(source_row["product_catalog_id"]),
+ "source_payload_sha256": digest,
+ "created": False,
+ }
+
+ # Serialize first-time definitions of the same explicit product code.
+ # The source lock above protects replay, while this lock prevents two
+ # different source records from racing the catalog's unique code.
+ await conn.execute(
+ "select pg_advisory_xact_lock(hashtextextended($1, 0))",
+ entry.product_code.strip(),
+ )
+
+ parent_id = None
+ if entry.parent_product_code is not None:
+ parent = await conn.fetchrow(
+ "select product_catalog_id from product_catalog "
+ "where product_catalog_code = $1",
+ entry.parent_product_code.strip(),
+ )
+ if parent is None:
+ raise ProductCatalogParentMissing("parent product code is not provisioned")
+ parent_id = parent["product_catalog_id"]
+
+ catalog = await conn.fetchrow(
+ "select product_catalog_id, canonical_product_name, product_level_code, "
+ "parent_product_catalog_id from product_catalog "
+ "where product_catalog_code = $1 for update",
+ entry.product_code.strip(),
+ )
+ if catalog is None:
+ catalog = await conn.fetchrow(
+ "insert into product_catalog "
+ "(canonical_product_name, product_level_code, parent_product_catalog_id, product_catalog_code) "
+ "values ($1, $2, $3, $4) returning product_catalog_id, "
+ "canonical_product_name, product_level_code, parent_product_catalog_id",
+ entry.preferred_label.strip(),
+ entry.product_level_code,
+ parent_id,
+ entry.product_code.strip(),
+ )
+ elif (
+ catalog["canonical_product_name"] != entry.preferred_label.strip()
+ or catalog["product_level_code"] != entry.product_level_code
+ or catalog["parent_product_catalog_id"] != parent_id
+ ):
+ raise ProductCatalogProvisioningConflict(
+ "the product code already has a different governed definition"
+ )
+ product_id = catalog["product_catalog_id"]
+ await conn.execute(
+ "insert into product_catalog_source_record "
+ "(corporate_entity_id, source_system_code, source_record_key, "
+ "product_catalog_id, source_payload_sha256, preferred_label_text, "
+ "imported_by_account_id) values ($1::uuid, $2, $3, $4, $5, $6, $7::uuid)",
+ entry.corporate_entity_id,
+ entry.source_system_code,
+ entry.source_record_key.strip(),
+ product_id,
+ digest,
+ entry.preferred_label.strip(),
+ imported_by_account_id,
+ )
+ for normalized, alias in aliases:
+ await conn.execute(
+ "insert into product_catalog_alias "
+ "(product_catalog_id, normalized_alias_text, alias_text) "
+ "values ($1, $2, $3) on conflict (product_catalog_id, normalized_alias_text) "
+ "do nothing",
+ product_id,
+ normalized,
+ alias,
+ )
+ await conn.execute(
+ "insert into product_catalog_alias_source "
+ "(product_catalog_id, normalized_alias_text, source_alias_text, "
+ "corporate_entity_id, source_system_code, source_record_key) "
+ "values ($1, $2, $3, $4::uuid, $5, $6) "
+ "on conflict do nothing",
+ product_id,
+ normalized,
+ alias,
+ entry.corporate_entity_id,
+ entry.source_system_code,
+ entry.source_record_key.strip(),
+ )
+ return {
+ "product_catalog_id": str(product_id),
+ "source_payload_sha256": digest,
+ "created": True,
+ }
diff --git a/backend/app/product_semantic_ingestion.py b/backend/app/product_semantic_ingestion.py
new file mode 100644
index 000000000..d4705b085
--- /dev/null
+++ b/backend/app/product_semantic_ingestion.py
@@ -0,0 +1,208 @@
+"""Persist product mentions after fail-closed normalized catalog resolution."""
+
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+from lineageweave.product_semantics import (
+ ProductEvidenceSource,
+ ProductExtractionResult,
+ ProductMention,
+ ProductRelationTarget,
+ ResolvedProductMention,
+ normalize_product_alias,
+ product_analysis_input_sha256,
+ resolve_product_mention,
+)
+
+
+class _Connection(Protocol):
+ def transaction(self) -> Any:
+ """Open an atomic database transaction."""
+ pass # pragma: no cover - structural protocol declaration
+
+ async def fetch(self, query: str, *args: object) -> list[Any]:
+ """Fetch parameterized rows."""
+ pass # pragma: no cover - structural protocol declaration
+
+ async def fetchval(self, query: str, *args: object) -> Any:
+ """Fetch one scalar value."""
+ pass # pragma: no cover - structural protocol declaration
+
+ async def fetchrow(self, query: str, *args: object) -> Any:
+ """Fetch one row."""
+ pass # pragma: no cover - structural protocol declaration
+
+ async def execute(self, query: str, *args: object) -> Any:
+ """Execute one parameterized statement."""
+ pass # pragma: no cover - structural protocol declaration
+
+
+async def resolve_product_mentions(
+ conn: _Connection, mentions: tuple[ProductMention, ...]
+) -> tuple[ResolvedProductMention, ...]:
+ """Resolve every mention by exact normalized alias, retaining ties."""
+ resolved: list[ResolvedProductMention] = []
+ for mention in mentions:
+ rows = await conn.fetch(
+ "select product_catalog_id from product_catalog_alias "
+ "where normalized_alias_text = $1 order by product_catalog_id",
+ normalize_product_alias(mention.extracted_product_name),
+ )
+ resolved.append(
+ resolve_product_mention(
+ mention, tuple(str(row["product_catalog_id"]) for row in rows)
+ )
+ )
+ return tuple(resolved)
+
+
+async def load_current_product_relation_targets(
+ conn: _Connection,
+ post_id: str,
+ source_body_digest: str,
+ expected_operations_input_sha256: str | None,
+) -> tuple[ProductRelationTarget, ...]:
+ """Load relation targets that remain bound to the exact focal evidence."""
+ operation_rows = await conn.fetch(
+ "select fact.case_kind_code, fact.fact_ordinal, fact.fact_type_code, "
+ "fact.value_text from operations_case_fact fact "
+ "join operations_case_analysis analysis on analysis.post_id = fact.post_id "
+ "where fact.post_id = $1 and analysis.source_body_sha256 = $2 "
+ "and $3::text is not null and analysis.analysis_input_sha256 = $3 "
+ "order by fact.case_kind_code, fact.fact_ordinal",
+ post_id,
+ source_body_digest,
+ expected_operations_input_sha256,
+ )
+ project_rows = await conn.fetch(
+ "select project.project_key, project.project_name "
+ "from post_project_mention project join source_post source "
+ "on source.post_id = project.post_id where project.post_id = $1 "
+ "and encode(sha256(convert_to(coalesce(source.post_body, ''), 'UTF8')), 'hex') = $2 "
+ "and btrim(project.evidence_text) <> '' "
+ "and strpos(coalesce(source.post_body, ''), project.evidence_text) > 0 "
+ "order by project.project_key",
+ post_id,
+ source_body_digest,
+ )
+ return tuple(
+ ProductRelationTarget(
+ f"operations_fact:{row['case_kind_code']}:{row['fact_ordinal']}",
+ "operations_fact",
+ f"{row['fact_type_code']}: {row['value_text']}",
+ (post_id, str(row["case_kind_code"]), str(row["fact_ordinal"])),
+ )
+ for row in operation_rows
+ ) + tuple(
+ ProductRelationTarget(
+ f"project:{row['project_key']}",
+ "project",
+ str(row["project_name"]),
+ (post_id, str(row["project_key"])),
+ )
+ for row in project_rows
+ )
+
+
+async def persist_product_mentions(
+ conn: _Connection,
+ post_id: str,
+ analysis_input_sha256: str,
+ orchestrator_session_id: str,
+ mentions: tuple[ResolvedProductMention, ...],
+ result: ProductExtractionResult,
+ *,
+ expected_operations_input_sha256: str | None,
+) -> None:
+ """Replace products only while their complete evidence window is current."""
+ async with conn.transaction():
+ current_source = await conn.fetchrow(
+ "select coalesce(post_body, '') as post_body, "
+ "encode(sha256(convert_to(coalesce(post_body, ''), 'UTF8')), 'hex') "
+ "as source_body_sha256 "
+ "from source_post where post_id = $1::uuid for update",
+ post_id,
+ )
+ if (
+ current_source is None
+ or current_source["source_body_sha256"] != result.source_revision_digest
+ ):
+ raise ValueError("product result no longer matches the source revision")
+ current_targets = await load_current_product_relation_targets(
+ conn,
+ post_id,
+ result.source_revision_digest,
+ expected_operations_input_sha256,
+ )
+ current_input_sha256 = product_analysis_input_sha256(
+ (ProductEvidenceSource(post_id, str(current_source["post_body"])),),
+ current_targets,
+ )
+ if current_input_sha256 != analysis_input_sha256:
+ raise ValueError("product result no longer matches the relation targets")
+ await conn.execute("delete from post_product_analysis where post_id = $1", post_id)
+ await conn.execute(
+ "insert into post_product_analysis "
+ "(post_id, source_body_sha256, analysis_input_sha256, orchestrator_session_id, "
+ "orchestrator_model_receipt) values ($1, $2, $3, $4, $5)",
+ post_id,
+ result.source_revision_digest,
+ analysis_input_sha256,
+ orchestrator_session_id,
+ result.orchestrator_model_receipt,
+ )
+ for ordinal, resolved in enumerate(mentions):
+ mention = resolved.mention
+ await conn.execute(
+ "insert into post_product_mention "
+ "(post_id, mention_ordinal, product_catalog_id, extracted_product_name, "
+ "resolution_status_code, evidence_text, evidence_post_id, evidence_input_sha256) "
+ "values ($1, $2, $3, $4, $5, $6, $7, $8)",
+ post_id,
+ ordinal,
+ resolved.product_catalog_id,
+ mention.extracted_product_name,
+ resolved.resolution_status_code,
+ mention.evidence_text,
+ mention.evidence_post_id,
+ mention.evidence_input_sha256,
+ )
+ for relation in result.extraction.relations:
+ if relation.target_kind_code == "operations_fact":
+ target_post_id, case_kind_code, fact_ordinal = relation.target_locator
+ if target_post_id != post_id:
+ raise ValueError("product relation target is outside the focal post")
+ await conn.execute(
+ "insert into product_operations_fact_relation "
+ "(post_id, mention_ordinal, case_kind_code, fact_ordinal, "
+ "relation_type_code, evidence_text, evidence_post_id, evidence_input_sha256) "
+ "values ($1, $2, $3, $4, $5, $6, $7, $8)",
+ post_id,
+ relation.mention_ordinal,
+ case_kind_code,
+ int(fact_ordinal),
+ relation.relation_type_code,
+ relation.evidence_text,
+ relation.evidence_post_id,
+ relation.evidence_input_sha256,
+ )
+ elif relation.target_kind_code == "project":
+ target_post_id, project_key = relation.target_locator
+ if target_post_id != post_id:
+ raise ValueError("product relation target is outside the focal post")
+ await conn.execute(
+ "insert into product_project_relation "
+ "(post_id, mention_ordinal, project_key, relation_type_code, "
+ "evidence_text, evidence_post_id, evidence_input_sha256) "
+ "values ($1, $2, $3, $4, $5, $6, $7)",
+ post_id,
+ relation.mention_ordinal,
+ project_key,
+ relation.relation_type_code,
+ relation.evidence_text,
+ relation.evidence_post_id,
+ relation.evidence_input_sha256,
+ )
+ else: # pragma: no cover - parser owns the closed vocabulary
+ raise ValueError("unsupported product relation target kind")
diff --git a/backend/app/project_history.py b/backend/app/project_history.py
index 9389ef5fd..fe467f75c 100644
--- a/backend/app/project_history.py
+++ b/backend/app/project_history.py
@@ -27,19 +27,27 @@ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]:
_PROJECT_MATCH = """
(
lower(btrim(normalize(coalesce(post.source_project_code, ''), NFKC), {whitespace})) = $1
- or lower(btrim(normalize(coalesce(post.source_project_name, ''), NFKC), {whitespace})) = $1
or exists (
select 1
from post_project_mention mention
where mention.post_id = post.post_id
- and (
- lower(btrim(normalize(mention.project_key, NFKC), {whitespace})) = $1
- or lower(btrim(normalize(mention.project_name, NFKC), {whitespace})) = $1
- )
+ and lower(btrim(normalize(mention.project_key, NFKC), {whitespace})) = $1
)
)
""".format(whitespace=_ASCII_EDGE_WHITESPACE)
+_PROJECT_CANDIDATES = """
+matching_post as materialized (
+ select post_id
+ from source_post
+ where lower(btrim(normalize(coalesce(source_project_code, ''), NFKC), {whitespace})) = $1
+ union
+ select post_id
+ from post_project_mention
+ where lower(btrim(normalize(project_key, NFKC), {whitespace})) = $1
+)
+""".format(whitespace=_ASCII_EDGE_WHITESPACE)
_EVENT_SQL = f"""
+with {_PROJECT_CANDIDATES}
select post.post_id,
post.post_title,
post.created_at,
@@ -47,14 +55,14 @@ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]:
post.voc_type_code,
post.source_stage_code,
post.source_detail_state_code
- from source_post post
+ from matching_post matching
+ join source_post post on post.post_id = matching.post_id
where (post.visibility_code = 'public'
or (post.corporate_entity_id::text = any($2::text[])
and (cardinality($3::text[]) = 0
or post.process_unit_id::text = any($3::text[]))))
and {_ELIGIBILITY}
and post.created_at <= $4
- and {_PROJECT_MATCH}
order by coalesce(post.event_occurred_at, post.created_at), post.created_at, post.post_id
limit $5
"""
@@ -133,8 +141,31 @@ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]:
order by role.post_id, role.actor_type_code, role.actor_name, role.responsibility
"""
_EDGE_SQL = """
-select edge.parent_post_id, edge.child_post_id, edge.fused_score
+select edge.parent_post_id, edge.child_post_id, edge.fused_score,
+ temporal.observed as temporal_observed,
+ temporal.allen_relations,
+ temporal.artifact_digest_sha256
from post_lineage_edge edge
+ left join lateral (
+ select relation.observed,
+ array_agg(kind.relation_code order by kind.relation_ordinal) as allen_relations,
+ artifact.artifact_digest_sha256
+ from project_journey_temporal_relation relation
+ join project_journey_temporal_artifact artifact
+ on artifact.analysis_run_id = relation.analysis_run_id
+ join analysis_run temporal_run
+ on temporal_run.analysis_run_id = artifact.analysis_run_id
+ join project_journey_temporal_relation_kind kind
+ on kind.analysis_run_id = relation.analysis_run_id
+ and kind.left_post_id = relation.left_post_id
+ and kind.right_post_id = relation.right_post_id
+ where relation.left_post_id = edge.parent_post_id
+ and relation.right_post_id = edge.child_post_id
+ and temporal_run.knowledge_cutoff <= $2
+ group by relation.observed, artifact.artifact_digest_sha256, artifact.admitted_at
+ order by artifact.admitted_at desc, artifact.artifact_digest_sha256 desc
+ limit 1
+ ) temporal on true
where edge.parent_post_id = any($1::uuid[])
and edge.child_post_id = any($1::uuid[])
order by edge.child_post_id, edge.parent_post_id
@@ -219,6 +250,7 @@ async def fetch_project_history_projection(
conn,
visible_ids=visible_ids,
normalized_key=normalized_key,
+ knowledge_cutoff=knowledge_cutoff,
)
return build_project_history_projection(
project_key=project_key,
@@ -237,10 +269,11 @@ async def _fetch_project_children(
*,
visible_ids: Sequence[str],
normalized_key: str,
+ knowledge_cutoff: datetime,
) -> tuple[list[Mapping[str, Any]], list[Mapping[str, Any]], list[Mapping[str, Any]]]:
"""Fetch only child evidence whose endpoints are already authorized."""
matches = list(await conn.fetch(_MATCH_SQL, list(visible_ids), normalized_key))
roles = list(await conn.fetch(_ROLE_SQL, list(visible_ids)))
- edges = list(await conn.fetch(_EDGE_SQL, list(visible_ids)))
+ edges = list(await conn.fetch(_EDGE_SQL, list(visible_ids), knowledge_cutoff))
return matches, roles, edges
diff --git a/backend/app/project_journey_temporal.py b/backend/app/project_journey_temporal.py
new file mode 100644
index 000000000..15c6bc572
--- /dev/null
+++ b/backend/app/project_journey_temporal.py
@@ -0,0 +1,117 @@
+"""Persist provider-owned temporal evidence for already-admitted journey edges."""
+
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+from lineageweave.temporal_journey_artifact import (
+ ALLEN_RELATIONS,
+ TemporalJourneyArtifact,
+ parse_temporal_journey_artifact,
+)
+
+
+class TemporalArtifactConnection(Protocol):
+ """Minimal transaction-scoped database port for artifact admission."""
+
+ async def fetchrow(self, query: str, *args: object) -> Any:
+ """Read one binding row."""
+
+ pass
+
+ async def execute(self, query: str, *args: object) -> Any:
+ """Execute one immutable persistence statement."""
+
+ pass
+
+ async def executemany(self, query: str, args: list[tuple[object, ...]]) -> Any:
+ """Execute bounded normalized child inserts."""
+
+ pass
+
+
+class TemporalArtifactAdmissionError(ValueError):
+ """The artifact cannot be bound to the declared persisted run."""
+
+
+async def persist_project_journey_temporal_artifact(
+ conn: TemporalArtifactConnection,
+ *,
+ analysis_run_id: str,
+ payload: bytes,
+ expected_run_id: str,
+ expected_snapshot_id: str,
+ expected_input_digest_sha256: str,
+ expected_artifact_digest_sha256: str,
+) -> TemporalJourneyArtifact:
+ """Validate and immutably persist temporal evidence for existing edges.
+
+ The foreign key to ``post_lineage_edge`` is the semantic admission gate:
+ interval order can corroborate an admitted predecessor, but cannot create
+ a predecessor, branch, responsibility handoff, or causal transition.
+ """
+
+ artifact = parse_temporal_journey_artifact(
+ payload,
+ expected_run_id=expected_run_id,
+ expected_snapshot_id=expected_snapshot_id,
+ expected_input_digest_sha256=expected_input_digest_sha256,
+ expected_artifact_digest_sha256=expected_artifact_digest_sha256,
+ )
+ binding = await conn.fetchrow(
+ "select remote_run_id from analysis_run_tepp_result where analysis_run_id = $1::uuid",
+ analysis_run_id,
+ )
+ if binding is None or str(binding["remote_run_id"]) != expected_run_id:
+ raise TemporalArtifactAdmissionError("artifact run does not match a persisted terminal result")
+ existing = await conn.fetchrow(
+ "select artifact_digest_sha256 from project_journey_temporal_artifact "
+ "where analysis_run_id = $1::uuid for update",
+ analysis_run_id,
+ )
+ if existing is not None:
+ if str(existing["artifact_digest_sha256"]) != expected_artifact_digest_sha256:
+ raise TemporalArtifactAdmissionError("analysis run already has a different artifact")
+ return artifact
+ await conn.execute(
+ "insert into project_journey_temporal_artifact "
+ "(analysis_run_id, remote_run_id, schema_version, snapshot_id, input_digest_sha256, artifact_digest_sha256) "
+ "values ($1::uuid, $2, $3, $4, $5, $6)",
+ analysis_run_id,
+ expected_run_id,
+ "tepp.tdt_chronos_interval_consistency.v1",
+ expected_snapshot_id,
+ expected_input_digest_sha256,
+ expected_artifact_digest_sha256,
+ )
+ relation_rows = [
+ (analysis_run_id, relation.left_event_id, relation.right_event_id, relation.observed)
+ for relation in artifact.relations
+ ]
+ await conn.executemany(
+ "insert into project_journey_temporal_relation "
+ "(analysis_run_id, left_post_id, right_post_id, observed) "
+ "values ($1::uuid, $2::uuid, $3::uuid, $4)",
+ relation_rows,
+ )
+ await conn.executemany(
+ "insert into project_journey_temporal_relation_kind "
+ "(analysis_run_id, left_post_id, right_post_id, relation_code, relation_ordinal) "
+ "values ($1::uuid, $2::uuid, $3::uuid, $4, $5)",
+ [
+ (analysis_run_id, relation.left_event_id, relation.right_event_id, code, ALLEN_RELATIONS.index(code))
+ for relation in artifact.relations
+ for code in relation.allen_relations
+ ],
+ )
+ await conn.executemany(
+ "insert into project_journey_temporal_support "
+ "(analysis_run_id, left_post_id, right_post_id, assertion_ordinal) "
+ "values ($1::uuid, $2::uuid, $3::uuid, $4)",
+ [
+ (analysis_run_id, relation.left_event_id, relation.right_event_id, ordinal)
+ for relation in artifact.relations
+ for ordinal in relation.support_assertion_ordinals
+ ],
+ )
+ return artifact
diff --git a/backend/app/ranking_ingestion.py b/backend/app/ranking_ingestion.py
index 80b7558d3..2a146e548 100644
--- a/backend/app/ranking_ingestion.py
+++ b/backend/app/ranking_ingestion.py
@@ -6,12 +6,93 @@
from __future__ import annotations
+from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Callable, Mapping
+from backend.app.post_eligibility import source_post_scope_sql
+
if TYPE_CHECKING:
import asyncpg
-__all__ = ["load_visible_ranking_posts"]
+__all__ = [
+ "load_ranking_context_choices",
+ "load_selected_ranking_rows",
+ "load_visible_ranking_posts",
+]
+
+
+async def load_ranking_context_choices(
+ conn: "asyncpg.Connection",
+ corporate_entity_ids: Sequence[str],
+ process_unit_ids: Sequence[str],
+) -> list[dict[str, Any]]:
+ """List persisted topic/context choices supported by an authorized post."""
+ rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ f"""
+ select distinct membership.topic_model_run_id::text,
+ influence.topic_influence_run_id::text as influence_run_id,
+ influence.topic_index, membership.dimension_code as dimension,
+ membership.context_id as context, definition.context_label,
+ post.visibility_code, post.corporate_entity_id, post.process_unit_id
+ from topic_post_context_influence influence
+ join topic_context_membership membership
+ on membership.topic_model_run_id = influence.topic_model_run_id
+ and membership.topic_context_membership_id = influence.topic_context_membership_id
+ join topic_context_definition definition
+ on definition.topic_model_run_id = membership.topic_model_run_id
+ and definition.dimension_code = membership.dimension_code
+ and definition.context_id = membership.context_id
+ join source_post post on post.post_id = membership.source_post_id
+ where influence.diagnostic_status_code = 'accepted'
+ and {source_post_scope_sql('post')}
+ order by membership.topic_model_run_id::text,
+ influence.topic_influence_run_id::text, influence.topic_index,
+ membership.dimension_code, membership.context_id
+ """,
+ list(corporate_entity_ids),
+ list(process_unit_ids),
+ )
+ keys = (
+ "topic_model_run_id", "influence_run_id", "topic_index",
+ "dimension", "context", "context_label",
+ )
+ return [{key: row[key] for key in keys} for row in rows]
+
+
+async def load_selected_ranking_rows(
+ conn: "asyncpg.Connection",
+ selection: Mapping[str, Any],
+ corporate_entity_ids: Sequence[str],
+ process_unit_ids: Sequence[str],
+) -> list[dict[str, Any]]:
+ """Load one exact accepted influence population before RankWeave fusion."""
+ rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ f"""
+ select post.post_id::text, post.post_title,
+ coalesce(post.event_occurred_at, post.created_at) as event_time,
+ post.visibility_code, post.corporate_entity_id, post.process_unit_id,
+ influence.influence_value, influence.uncertainty_method_code,
+ influence.uncertainty_lower_value, influence.uncertainty_upper_value,
+ membership.evidence_sha256,
+ membership.provenance_assertion_id::text as provenance_assertion_id
+ from topic_post_context_influence influence
+ join topic_context_membership membership
+ on membership.topic_model_run_id = influence.topic_model_run_id
+ and membership.topic_context_membership_id = influence.topic_context_membership_id
+ join source_post post on post.post_id = membership.source_post_id
+ where influence.topic_model_run_id = $3::uuid
+ and influence.topic_influence_run_id = $4::uuid
+ and influence.topic_index = $5
+ and membership.dimension_code = $6
+ and membership.context_id = $7
+ and influence.diagnostic_status_code = 'accepted'
+ and {source_post_scope_sql('post')}
+ """,
+ list(corporate_entity_ids), list(process_unit_ids),
+ selection["topic_model_run_id"], selection["influence_run_id"],
+ selection["topic_index"], selection["dimension"], selection["context"],
+ )
+ return [dict(row) for row in rows]
async def load_visible_ranking_posts(
diff --git a/backend/app/source_post_revision.py b/backend/app/source_post_revision.py
index 70953cfd2..05a1e45b8 100644
--- a/backend/app/source_post_revision.py
+++ b/backend/app/source_post_revision.py
@@ -93,6 +93,31 @@ async def fetch_known_at_revision(
}
+async def fetch_known_at_revision_metadata(
+ conn: "asyncpg.Connection",
+ post_id: str,
+ as_of: datetime,
+) -> dict[str, str] | None:
+ """Return cutoff revision identity and clocks without detoasting its body."""
+ row = await conn.fetchrow(
+ "select source_post_revision_id, post_title, written_at "
+ "from source_post_revision "
+ "where post_id = $1 and written_at <= $2 "
+ "and (superseded_at is null or superseded_at > $2) "
+ "order by written_at desc limit 1",
+ post_id,
+ as_of,
+ )
+ if row is None:
+ return None
+ return {
+ "source_post_revision_id": str(row["source_post_revision_id"]),
+ "post_title": row["post_title"],
+ "written_at": _iso(row["written_at"]),
+ "as_of": _iso(as_of),
+ }
+
+
async def fetch_known_at_revisions(
conn: "asyncpg.Connection",
post_ids: list[str],
diff --git a/backend/app/source_research_ingestion.py b/backend/app/source_research_ingestion.py
new file mode 100644
index 000000000..257ac3f76
--- /dev/null
+++ b/backend/app/source_research_ingestion.py
@@ -0,0 +1,304 @@
+"""Load source leads, run public research, and persist citations.
+
+Private posts fail closed before any search or retrieval. Already-checked
+leads retain their last determinate public evidence when a later provider
+attempt is unavailable.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from datetime import datetime
+
+import asyncpg
+
+from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
+from lineageweave.http_client import HttpClientError
+from lineageweave.source_reference_research import (
+ NO_LEAD_UNAVAILABLE,
+ PRIVATE_POST_UNAVAILABLE,
+ VISIBILITY_PUBLIC,
+ SourceResearchCitation,
+ SourceResearchClient,
+ SourceResearchLead,
+ select_source_research_leads,
+ unavailable_citation,
+)
+
+
+@dataclass(frozen=True)
+class SourceResearchRun:
+ """One post-scoped research attempt, including fail-closed unavailability."""
+
+ post_id: str
+ visibility_code: str
+ citations: tuple[SourceResearchCitation, ...]
+ unavailable_reason: str | None = None
+
+
+async def load_source_research_leads(
+ conn: asyncpg.Connection,
+ post_id: str,
+ maximum_leads: int,
+) -> tuple[SourceResearchLead, ...]:
+ """Read persisted semantic units and image regions for ``post_id``."""
+
+ units = await conn.fetch(
+ """
+ select post_content_unit_id::text as post_content_unit_id,
+ unit_index,
+ unit_kind_code,
+ unit_text
+ from post_content_unit
+ where post_id = $1
+ order by unit_index
+ """,
+ post_id,
+ )
+ regions = await conn.fetch(
+ """
+ select region.post_content_image_region_id::text as post_content_image_region_id,
+ unit.unit_index as source_unit_index,
+ region.region_index,
+ region.caption,
+ region.extracted_text
+ from post_content_image_region region
+ join post_content_image image
+ on image.post_content_image_id = region.post_content_image_id
+ join post_content_unit unit
+ on unit.post_content_unit_id = image.post_content_unit_id
+ where unit.post_id = $1
+ order by unit.unit_index, region.region_index,
+ region.post_content_image_region_id
+ """,
+ post_id,
+ )
+ return select_source_research_leads(
+ [dict(row) for row in units],
+ [dict(row) for row in regions],
+ maximum_leads=maximum_leads,
+ )
+
+
+async def list_source_research_citations(
+ conn: asyncpg.Connection,
+ post_id: str,
+) -> list[dict[str, object]]:
+ """Return persisted citations for one authorized post, newest first."""
+
+ rows = await conn.fetch(
+ """
+ select lead_kind_code,
+ lead_source_unit_id::text as lead_source_unit_id,
+ lead_image_region_id::text as lead_image_region_id,
+ lead_excerpt_text,
+ search_query_text,
+ evidence_url,
+ evidence_title_text,
+ evidence_excerpt_text,
+ judgment_code,
+ rationale_text,
+ next_action_text,
+ checked_at
+ from source_research_citation citation
+ left join post_content_unit unit
+ on unit.post_content_unit_id = citation.lead_source_unit_id
+ left join post_content_image_region region
+ on region.post_content_image_region_id = citation.lead_image_region_id
+ left join post_content_image image
+ on image.post_content_image_id = region.post_content_image_id
+ left join post_content_unit image_unit
+ on image_unit.post_content_unit_id = image.post_content_unit_id
+ where citation.post_id = $1
+ order by citation.checked_at desc,
+ case when citation.lead_source_unit_id is not null then 0 else 1 end,
+ unit.unit_index,
+ image_unit.unit_index,
+ region.region_index,
+ citation.source_research_citation_id
+ """,
+ post_id,
+ )
+ return [dict(row) for row in rows]
+
+
+async def list_ask_source_references(
+ conn: asyncpg.Connection,
+ post_ids: list[str],
+ *,
+ checked_by: datetime | None = None,
+) -> list[dict[str, object]]:
+ """Return persisted, publication-eligible public references for cited posts.
+
+ ``post_ids`` has already crossed the Ask authorization boundary. The
+ query rechecks current publication eligibility so a visibility or source
+ lifecycle change cannot leak a citation between retrieval and delivery.
+ A cutoff answer receives only citations that already existed by its
+ cutoff; absent determinate evidence remains absent rather than invented.
+ """
+
+ if not post_ids:
+ return []
+ rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ f"""
+ select citation.post_id::text as post_id,
+ citation.lead_kind_code,
+ citation.evidence_url,
+ citation.evidence_title_text,
+ citation.evidence_excerpt_text,
+ citation.judgment_code,
+ citation.next_action_text,
+ citation.checked_at
+ from source_research_citation citation
+ join source_post post on post.post_id = citation.post_id
+ where citation.post_id = any($1::uuid[])
+ and post.visibility_code = 'public'
+ and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
+ and citation.judgment_code in ('research_supported', 'research_refuted')
+ and citation.evidence_url is not null
+ and ($2::timestamptz is null or citation.checked_at <= $2)
+ order by array_position($1::uuid[], citation.post_id),
+ citation.checked_at desc,
+ citation.source_research_citation_id
+ """,
+ post_ids,
+ checked_by,
+ )
+ return [dict(row) for row in rows]
+
+
+async def persist_source_research_citation(
+ conn: asyncpg.Connection,
+ post_id: str,
+ citation: SourceResearchCitation,
+) -> None:
+ """Replace a lead citation without erasing determinate evidence on outage."""
+
+ values = (
+ post_id,
+ citation.lead_kind_code,
+ citation.lead_source_unit_id,
+ citation.lead_image_region_id,
+ citation.lead_excerpt_text,
+ citation.search_query_text,
+ citation.evidence_url,
+ citation.evidence_title_text,
+ citation.evidence_excerpt_text,
+ citation.judgment_code,
+ citation.rationale_text,
+ citation.next_action_text,
+ )
+ if citation.lead_source_unit_id is not None:
+ await conn.execute(
+ """
+ insert into source_research_citation (
+ post_id,
+ lead_kind_code,
+ lead_source_unit_id,
+ lead_image_region_id,
+ lead_excerpt_text,
+ search_query_text,
+ evidence_url,
+ evidence_title_text,
+ evidence_excerpt_text,
+ judgment_code,
+ rationale_text,
+ next_action_text
+ ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
+ on conflict (post_id, lead_source_unit_id)
+ where lead_source_unit_id is not null
+ do update set
+ lead_excerpt_text = excluded.lead_excerpt_text,
+ search_query_text = excluded.search_query_text,
+ evidence_url = excluded.evidence_url,
+ evidence_title_text = excluded.evidence_title_text,
+ evidence_excerpt_text = excluded.evidence_excerpt_text,
+ judgment_code = excluded.judgment_code,
+ rationale_text = excluded.rationale_text,
+ next_action_text = excluded.next_action_text,
+ checked_at = now()
+ where excluded.judgment_code <> 'research_unavailable'
+ or source_research_citation.judgment_code = 'research_unavailable'
+ """,
+ *values,
+ )
+ return
+ await conn.execute(
+ """
+ insert into source_research_citation (
+ post_id,
+ lead_kind_code,
+ lead_source_unit_id,
+ lead_image_region_id,
+ lead_excerpt_text,
+ search_query_text,
+ evidence_url,
+ evidence_title_text,
+ evidence_excerpt_text,
+ judgment_code,
+ rationale_text,
+ next_action_text
+ ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
+ on conflict (post_id, lead_image_region_id)
+ where lead_image_region_id is not null
+ do update set
+ lead_excerpt_text = excluded.lead_excerpt_text,
+ search_query_text = excluded.search_query_text,
+ evidence_url = excluded.evidence_url,
+ evidence_title_text = excluded.evidence_title_text,
+ evidence_excerpt_text = excluded.evidence_excerpt_text,
+ judgment_code = excluded.judgment_code,
+ rationale_text = excluded.rationale_text,
+ next_action_text = excluded.next_action_text,
+ checked_at = now()
+ where excluded.judgment_code <> 'research_unavailable'
+ or source_research_citation.judgment_code = 'research_unavailable'
+ """,
+ *values,
+ )
+
+
+
+async def research_post_sources_from_pool(
+ pool: asyncpg.Pool,
+ client: SourceResearchClient,
+ post_id: str,
+ visibility_code: str,
+) -> SourceResearchRun:
+ """Research public leads without holding a DB connection during web I/O."""
+
+ if visibility_code != VISIBILITY_PUBLIC:
+ return SourceResearchRun(
+ post_id=post_id,
+ visibility_code=visibility_code,
+ citations=(),
+ unavailable_reason=PRIVATE_POST_UNAVAILABLE,
+ )
+ async with pool.acquire() as conn:
+ leads = await load_source_research_leads(conn, post_id, client.maximum_leads)
+ if not leads:
+ return SourceResearchRun(
+ post_id=post_id,
+ visibility_code=visibility_code,
+ citations=(),
+ unavailable_reason=NO_LEAD_UNAVAILABLE,
+ )
+ citations: list[SourceResearchCitation] = []
+ for lead in leads:
+ try:
+ citation = await asyncio.to_thread(client.research, lead)
+ except (HttpClientError, OSError, ValueError):
+ citation = unavailable_citation(
+ lead,
+ "This item could not be checked. Review its existing evidence instead.",
+ )
+ citations.append(citation)
+ async with pool.acquire() as conn, conn.transaction():
+ for citation in citations:
+ await persist_source_research_citation(conn, post_id, citation)
+ return SourceResearchRun(
+ post_id=post_id,
+ visibility_code=visibility_code,
+ citations=tuple(citations),
+ )
diff --git a/backend/app/topic_influence_worker.py b/backend/app/topic_influence_worker.py
new file mode 100644
index 000000000..b170407c8
--- /dev/null
+++ b/backend/app/topic_influence_worker.py
@@ -0,0 +1,502 @@
+"""Produce persisted topic influence through the external Rust authority."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import uuid
+from datetime import datetime, timezone
+from typing import Any, Callable
+
+import asyncpg
+
+from lineageweave.http_client import HttpAdmissionDeferred, HttpClientError
+from lineageweave.topic_influence_client import (
+ TopicInfluenceClient,
+ TopicInfluenceInvalidResponse,
+ TopicInfluenceNotAvailable,
+ TopicInfluenceRequest,
+ TopicInfluenceResult,
+ build_topic_influence_request,
+)
+
+_logger = logging.getLogger(__name__)
+
+
+class TopicInfluenceInputChanged(RuntimeError):
+ """The source evidence changed after the external computation began."""
+
+
+class TopicInfluenceLeaseLost(RuntimeError):
+ """A different worker already owns or completed the claimed lease."""
+
+
+def _iso(value: object) -> str:
+ """Return a timezone-bearing ISO timestamp from trusted database evidence."""
+ if not isinstance(value, datetime):
+ raise ValueError("topic influence timestamp evidence is missing")
+ if value.tzinfo is None:
+ value = value.replace(tzinfo=timezone.utc)
+ return value.astimezone(timezone.utc).isoformat()
+
+
+async def load_topic_influence_request(
+ conn: asyncpg.Connection, topic_model_run_id: str
+) -> TopicInfluenceRequest:
+ """Load one exact TEPP artifact and its normalized membership evidence."""
+ model = await conn.fetchrow(
+ """
+ select model.topic_model_run_id, model.tepp_run_id,
+ model.tepp_artifact_sha256, model.posterior_draw_set_id,
+ model.posterior_draw_count, model.coordinate_kind_code,
+ snapshot.snapshot_sha256, analysis.knowledge_cutoff
+ from topic_model_run model
+ join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id
+ join analysis_source_snapshot snapshot
+ on snapshot.analysis_source_snapshot_id = analysis.analysis_source_snapshot_id
+ where model.topic_model_run_id = $1
+ and model.tepp_schema_version = 'tepp.topic_context_posterior.v1'
+ """,
+ topic_model_run_id,
+ )
+ if model is None:
+ raise ValueError("TEPP independent topic artifact is not bound")
+ topics = [
+ int(row["topic_index"])
+ for row in await conn.fetch(
+ """
+ select topic_index
+ from topic_definition
+ where topic_model_run_id = $1
+ order by topic_index
+ """,
+ topic_model_run_id,
+ )
+ ]
+ posts = await conn.fetch(
+ """
+ select distinct membership.source_post_id,
+ coalesce(post.event_occurred_at, post.created_at) as event_time
+ from topic_context_membership membership
+ join source_post post on post.post_id = membership.source_post_id
+ where membership.topic_model_run_id = $1
+ order by membership.source_post_id
+ """,
+ topic_model_run_id,
+ )
+ has_unbound_membership = await conn.fetchval(
+ """
+ select exists (
+ select 1
+ from topic_context_membership membership
+ left join provenance_assertion assertion
+ on assertion.assertion_id = membership.provenance_assertion_id
+ and assertion.relation_code = 'prov_was_derived_from'
+ left join provenance_resource_binding evidence
+ on evidence.resource_id = assertion.object_resource_id
+ and evidence.node_type_code = 'node_post'
+ and evidence.node_id = membership.source_post_id
+ where membership.topic_model_run_id = $1
+ and (assertion.assertion_id is null or evidence.resource_id is null)
+ )
+ """,
+ topic_model_run_id,
+ )
+ if has_unbound_membership:
+ raise ValueError("topic membership provenance is incomplete")
+ observations: list[dict[str, Any]] = []
+ for post in posts:
+ post_id = str(post["source_post_id"])
+ coordinates = [
+ {
+ "topic_index": int(row["topic_index"]),
+ "posterior_draw_ordinal": int(row["posterior_draw_ordinal"]),
+ "value": float(row["coordinate_value"]),
+ }
+ for row in await conn.fetch(
+ """
+ select topic_index, posterior_draw_ordinal, coordinate_value
+ from topic_post_coordinate
+ where topic_model_run_id = $1 and source_post_id = $2
+ order by topic_index, posterior_draw_ordinal
+ """,
+ topic_model_run_id,
+ post["source_post_id"],
+ )
+ ]
+ memberships = [
+ {
+ "membership_id": str(row["topic_context_membership_id"]),
+ "dimension_code": row["dimension_code"],
+ "context_id": row["context_id"],
+ "weight": float(row["membership_weight"]),
+ "valid_from": _iso(row["valid_from"]),
+ "valid_to": _iso(row["valid_to"]),
+ "evidence_sha256": row["evidence_sha256"],
+ "provenance_assertion_id": str(row["provenance_assertion_id"]),
+ }
+ for row in await conn.fetch(
+ """
+ select membership.topic_context_membership_id,
+ membership.dimension_code, membership.context_id,
+ membership.membership_weight, membership.valid_from,
+ membership.valid_to, membership.evidence_sha256,
+ membership.provenance_assertion_id
+ from topic_context_membership membership
+ join provenance_assertion assertion
+ on assertion.assertion_id = membership.provenance_assertion_id
+ and assertion.relation_code = 'prov_was_derived_from'
+ join provenance_resource_binding evidence
+ on evidence.resource_id = assertion.object_resource_id
+ and evidence.node_type_code = 'node_post'
+ and evidence.node_id = membership.source_post_id
+ where membership.topic_model_run_id = $1
+ and membership.source_post_id = $2
+ order by membership.dimension_code, membership.context_id,
+ membership.topic_context_membership_id
+ """,
+ topic_model_run_id,
+ post["source_post_id"],
+ )
+ ]
+ observations.append(
+ {
+ "post_id": post_id,
+ "event_time": _iso(post["event_time"]),
+ "coordinates": coordinates,
+ "memberships": memberships,
+ }
+ )
+ return build_topic_influence_request(
+ tepp_run={
+ "tepp_run_id": model["tepp_run_id"],
+ "tepp_artifact_sha256": model["tepp_artifact_sha256"],
+ "source_snapshot_sha256": model["snapshot_sha256"],
+ "knowledge_cutoff": _iso(model["knowledge_cutoff"]),
+ "posterior_draw_set_id": model["posterior_draw_set_id"],
+ "posterior_draw_count": int(model["posterior_draw_count"]),
+ "coordinate_kind_code": model["coordinate_kind_code"],
+ "topic_model_run_id": str(model["topic_model_run_id"]),
+ },
+ topics=topics,
+ observations=observations,
+ )
+
+
+async def claim_topic_influence_job(
+ pool: asyncpg.Pool,
+ lease_timeout_seconds: int,
+) -> tuple[str, TopicInfluenceRequest, str] | None:
+ """Lease the first complete queued request without holding provider I/O open."""
+ async with pool.acquire() as conn:
+ await conn.execute(
+ """
+ update topic_influence_job
+ set status_code = 'queued', started_at = null,
+ lease_expires_at = null, completed_at = null,
+ lease_token = null, failure_code = null,
+ request_sha256 = null,
+ not_before = clock_timestamp()
+ where status_code = 'running'
+ and lease_expires_at <= clock_timestamp()
+ """
+ )
+ candidates = await conn.fetch(
+ """
+ select topic_model_run_id
+ from topic_influence_job
+ where status_code = 'queued'
+ and not_before <= clock_timestamp()
+ order by queued_at, topic_model_run_id
+ """
+ )
+ for candidate in candidates:
+ run_id = str(candidate["topic_model_run_id"])
+ try:
+ request = await load_topic_influence_request(conn, run_id)
+ except (ValueError, TypeError, KeyError):
+ await conn.execute(
+ """
+ update topic_influence_job
+ set status_code = 'awaiting_evidence',
+ failure_code = 'input_evidence_incomplete',
+ completed_at = clock_timestamp()
+ where topic_model_run_id = $1 and status_code = 'queued'
+ """,
+ run_id,
+ )
+ # Close the transition race without polling incomplete input:
+ # evidence committed before the awaiting update is visible to
+ # this recheck; evidence committed afterwards fires a wake
+ # trigger against the already-awaiting row.
+ try:
+ await load_topic_influence_request(conn, run_id)
+ except (ValueError, TypeError, KeyError):
+ continue
+ await conn.execute(
+ """
+ update topic_influence_job
+ set status_code = 'queued', failure_code = null,
+ completed_at = null, not_before = clock_timestamp()
+ where topic_model_run_id = $1
+ and status_code = 'awaiting_evidence'
+ """,
+ run_id,
+ )
+ continue
+ async with conn.transaction():
+ lease_token = str(uuid.uuid4())
+ claimed = await conn.fetchval(
+ """
+ update topic_influence_job
+ set status_code = 'running', request_sha256 = $2,
+ attempt_count = attempt_count + 1,
+ started_at = clock_timestamp(), completed_at = null,
+ failure_code = null,
+ lease_token = $4::uuid,
+ lease_expires_at = clock_timestamp()
+ + make_interval(secs => $3)
+ where topic_model_run_id = $1 and status_code = 'queued'
+ returning topic_model_run_id
+ """,
+ run_id,
+ request.request_sha256,
+ lease_timeout_seconds,
+ lease_token,
+ )
+ if claimed is not None:
+ return run_id, request, lease_token
+ return None
+
+
+async def persist_topic_influence_result(
+ pool: asyncpg.Pool,
+ topic_model_run_id: str,
+ request: TopicInfluenceRequest,
+ result: TopicInfluenceResult,
+ lease_token: str,
+) -> None:
+ """Persist one complete result after rechecking the current input digest."""
+ payload = result.payload
+ async with pool.acquire() as conn:
+ async with conn.transaction():
+ job = await conn.fetchrow(
+ """
+ select request_sha256, lease_token::text as lease_token
+ from topic_influence_job
+ where topic_model_run_id = $1 and status_code = 'running'
+ for update
+ """,
+ topic_model_run_id,
+ )
+ if (
+ job is None
+ or job["request_sha256"] != request.request_sha256
+ or job["lease_token"] != lease_token
+ ):
+ raise TopicInfluenceLeaseLost(
+ "topic influence job lease no longer matches"
+ )
+ try:
+ current = await load_topic_influence_request(conn, topic_model_run_id)
+ except (ValueError, TypeError, KeyError) as exc:
+ raise TopicInfluenceInputChanged(
+ "topic influence evidence became incomplete during computation"
+ ) from exc
+ if current.request_sha256 != request.request_sha256:
+ raise TopicInfluenceInputChanged(
+ "topic influence input changed during computation"
+ )
+ influence_run_id = await conn.fetchval(
+ """
+ insert into topic_influence_run
+ (topic_model_run_id, fast_mlsirm_schema_version,
+ fast_mlsirm_version, fast_mlsirm_code_revision,
+ fast_mlsirm_artifact_sha256, reported_tepp_run_id,
+ reported_snapshot_sha256, reported_knowledge_cutoff,
+ membership_fingerprint_sha256, compute_backend_code,
+ precision_code, posterior_draw_coverage,
+ convergence_status_code, identification_status_code,
+ parity_status_code)
+ values ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz, $9,
+ $10, $11, $12, $13, $14, $15)
+ returning topic_influence_run_id
+ """,
+ topic_model_run_id,
+ payload["schema_version"],
+ payload["producer_version"],
+ payload["code_revision"],
+ payload["artifact_sha256"],
+ payload["tepp_run_id"],
+ payload["source_snapshot_sha256"],
+ payload["knowledge_cutoff"],
+ payload["membership_fingerprint_sha256"],
+ payload["compute_backend_code"],
+ payload["precision_code"],
+ payload["posterior_draw_coverage"],
+ payload["convergence_status_code"],
+ payload["identification_status_code"],
+ payload["parity_status_code"],
+ )
+ for influence in payload["influences"]:
+ await conn.execute(
+ """
+ insert into topic_post_context_influence
+ (topic_model_run_id, topic_influence_run_id,
+ topic_context_membership_id, topic_index,
+ influence_value, uncertainty_method_code,
+ uncertainty_lower_value, uncertainty_upper_value,
+ diagnostic_status_code)
+ values ($1, $2, $3::uuid, $4, $5, $6, $7, $8, $9)
+ """,
+ topic_model_run_id,
+ influence_run_id,
+ influence["membership_id"],
+ influence["topic_index"],
+ influence["influence_value"],
+ influence["uncertainty_method_code"],
+ influence["uncertainty_lower_value"],
+ influence["uncertainty_upper_value"],
+ influence["diagnostic_status_code"],
+ )
+ await conn.execute(
+ """
+ update topic_influence_job
+ set status_code = 'succeeded', completed_at = clock_timestamp(),
+ lease_expires_at = null, lease_token = null
+ where topic_model_run_id = $1 and status_code = 'running'
+ and lease_token = $2::uuid
+ """,
+ topic_model_run_id,
+ lease_token,
+ )
+
+
+async def _fail_job(
+ pool: asyncpg.Pool, run_id: str, lease_token: str, failure_code: str
+) -> None:
+ """Record a bounded failure without persisting provider content."""
+ async with pool.acquire() as conn:
+ await conn.execute(
+ """
+ update topic_influence_job
+ set status_code = 'failed', failure_code = $3,
+ completed_at = clock_timestamp(), lease_expires_at = null,
+ lease_token = null, request_sha256 = null
+ where topic_model_run_id = $1 and status_code = 'running'
+ and lease_token = $2::uuid
+ """,
+ run_id,
+ lease_token,
+ failure_code,
+ )
+
+
+async def _defer_job(
+ pool: asyncpg.Pool, run_id: str, lease_token: str, retry_after_seconds: int
+) -> None:
+ """Requeue a remotely deferred job at the exact admitted retry instant."""
+ async with pool.acquire() as conn:
+ await conn.execute(
+ """
+ update topic_influence_job
+ set status_code = 'queued', started_at = null, completed_at = null,
+ failure_code = null,
+ not_before = clock_timestamp() + make_interval(secs => $3),
+ lease_expires_at = null, lease_token = null,
+ request_sha256 = null
+ where topic_model_run_id = $1 and status_code = 'running'
+ and lease_token = $2::uuid
+ """,
+ run_id,
+ lease_token,
+ retry_after_seconds,
+ )
+
+
+async def requeue_topic_influence_job(pool: asyncpg.Pool, run_id: str) -> bool:
+ """Explicitly requeue one failed job after an operator resolves its cause."""
+ async with pool.acquire() as conn:
+ updated = await conn.fetchval(
+ """
+ update topic_influence_job
+ set status_code = 'queued', started_at = null, completed_at = null,
+ failure_code = null, not_before = clock_timestamp(),
+ lease_expires_at = null, lease_token = null,
+ request_sha256 = null
+ where topic_model_run_id = $1 and status_code = 'failed'
+ returning topic_model_run_id
+ """,
+ run_id,
+ )
+ return updated is not None
+
+
+async def _release_changed_job(
+ pool: asyncpg.Pool, run_id: str, lease_token: str
+) -> None:
+ """Release a stale lease so the next claim rebuilds the changed request."""
+ async with pool.acquire() as conn:
+ await conn.execute(
+ """
+ update topic_influence_job
+ set status_code = 'queued', started_at = null, completed_at = null,
+ failure_code = null, not_before = clock_timestamp(),
+ lease_expires_at = null, lease_token = null,
+ request_sha256 = null
+ where topic_model_run_id = $1 and status_code = 'running'
+ and lease_token = $2::uuid
+ """,
+ run_id,
+ lease_token,
+ )
+
+
+async def process_topic_influence_job(
+ pool: asyncpg.Pool, client: TopicInfluenceClient
+) -> bool:
+ """Produce at most one queued result and return whether work was claimed."""
+ claimed = await claim_topic_influence_job(pool, client.lease_timeout_seconds)
+ if claimed is None:
+ return False
+ run_id, request, lease_token = claimed
+ try:
+ result = await asyncio.to_thread(client.estimate, request)
+ await persist_topic_influence_result(
+ pool, run_id, request, result, lease_token
+ )
+ except HttpAdmissionDeferred as exc:
+ await _defer_job(pool, run_id, lease_token, exc.retry_after_seconds)
+ except TopicInfluenceInputChanged:
+ await _release_changed_job(pool, run_id, lease_token)
+ except TopicInfluenceLeaseLost:
+ _logger.info("Topic influence lease changed before result persistence")
+ except (TopicInfluenceNotAvailable, HttpClientError, OSError, TimeoutError):
+ await _fail_job(pool, run_id, lease_token, "producer_unavailable")
+ except TopicInfluenceInvalidResponse:
+ await _fail_job(pool, run_id, lease_token, "producer_result_invalid")
+ except Exception: # noqa: BLE001 - failure is bounded and the worker continues.
+ _logger.exception("topic influence production failed")
+ await _fail_job(pool, run_id, lease_token, "persistence_failed")
+ return True
+
+
+async def run_topic_influence_worker(
+ pool: asyncpg.Pool,
+ client_factory: Callable[[], TopicInfluenceClient],
+ *,
+ poll_seconds: float,
+) -> None:
+ """Poll the durable lease table and keep the shared worker responsive."""
+ while True:
+ try:
+ worked = await process_topic_influence_job(pool, client_factory())
+ except (asyncpg.PostgresError, OSError, TimeoutError):
+ _logger.exception(
+ "Topic influence could not claim database work; verify database "
+ "connectivity before the next poll"
+ )
+ await asyncio.sleep(poll_seconds)
+ continue
+ if not worked:
+ await asyncio.sleep(poll_seconds)
diff --git a/backend/app/voice_classification_ingestion.py b/backend/app/voice_classification_ingestion.py
new file mode 100644
index 000000000..106cc035f
--- /dev/null
+++ b/backend/app/voice_classification_ingestion.py
@@ -0,0 +1,96 @@
+"""Persist receipt-bearing derived Voice assertions and their history."""
+
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+from lineageweave.voice_classification import VoiceClassificationResult
+
+
+class _Transaction(Protocol):
+ async def __aenter__(self) -> Any:
+ """Enter a database transaction."""
+ raise NotImplementedError
+
+ async def __aexit__(self, *args: object) -> bool:
+ """Leave a database transaction."""
+ raise NotImplementedError
+
+
+class _Connection(Protocol):
+ def transaction(self) -> _Transaction:
+ """Create a database transaction context."""
+ raise NotImplementedError
+
+ async def fetchval(self, query: str, *args: object) -> Any:
+ """Fetch one scalar value."""
+ raise NotImplementedError
+
+ async def fetch(self, query: str, *args: object) -> list[Any]:
+ """Fetch current derived assertion rows."""
+ raise NotImplementedError
+
+ async def execute(self, query: str, *args: object) -> str:
+ """Execute one persistence statement."""
+ raise NotImplementedError
+
+
+async def persist_derived_voice_classification(
+ conn: _Connection,
+ post_id: str,
+ result: VoiceClassificationResult,
+) -> None:
+ """Replace only current derived assertions after locking the exact source revision."""
+ async with conn.transaction():
+ current_digest = await conn.fetchval(
+ "select encode(sha256(convert_to(coalesce(post_body, ''), 'UTF8')), 'hex') "
+ "from source_post where post_id = $1::uuid for update",
+ post_id,
+ )
+ if current_digest != result.source_revision_digest:
+ raise ValueError(
+ "derived Voice result no longer matches the source revision"
+ )
+ prior_rows = await conn.fetch(
+ "select classification_assertion_id, voice_concept_code "
+ "from post_voice_classification_assertion where post_id = $1::uuid "
+ "and assertion_status_code = 'derived' and valid_to is null for update",
+ post_id,
+ )
+ prior_by_code = {
+ str(row["voice_concept_code"]): row["classification_assertion_id"]
+ for row in prior_rows
+ }
+ await conn.execute(
+ "update post_voice_classification_assertion set valid_to = clock_timestamp() "
+ "where post_id = $1::uuid and assertion_status_code = 'derived' and valid_to is null",
+ post_id,
+ )
+ for assertion in result.assertions:
+ await conn.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_span_start, "
+ "evidence_span_end, evidence_sha256, source_revision_digest, "
+ "orchestrator_model_receipt, supersedes_assertion_id) "
+ "values ($1::uuid, $2, 'derived', $3, $4, $5, $6, $7, $8)",
+ post_id,
+ assertion.voice_concept_code,
+ assertion.evidence_span_start,
+ assertion.evidence_span_end,
+ assertion.evidence_sha256,
+ result.source_revision_digest,
+ result.orchestrator_model_receipt,
+ prior_by_code.get(assertion.voice_concept_code),
+ )
+ await conn.execute(
+ "insert into post_voice_classification_analysis "
+ "(post_id, source_body_sha256, orchestrator_model_receipt, assertion_count) "
+ "values ($1::uuid, $2, $3, $4) on conflict (post_id) do update set "
+ "source_body_sha256 = excluded.source_body_sha256, "
+ "orchestrator_model_receipt = excluded.orchestrator_model_receipt, "
+ "assertion_count = excluded.assertion_count, analyzed_at = clock_timestamp()",
+ post_id,
+ result.source_revision_digest,
+ result.orchestrator_model_receipt,
+ len(result.assertions),
+ )
diff --git a/backend/app/voice_taxonomy.py b/backend/app/voice_taxonomy.py
new file mode 100644
index 000000000..54995aa31
--- /dev/null
+++ b/backend/app/voice_taxonomy.py
@@ -0,0 +1,262 @@
+"""Authorized aggregate reads for source-preserving voice assertions."""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any, Protocol
+
+from backend.app.post_eligibility import source_post_eligibility_sql
+
+
+class _Connection(Protocol):
+ async def fetchrow(self, query: str, *args: object) -> Any:
+ """Fetch one aggregate row with bound parameters."""
+ pass # pragma: no cover - structural protocol declaration
+
+
+async def warm_voice_taxonomy_read_statements(conn: _Connection) -> None:
+ """Prepare each exact read shape before the pool serves HTTP traffic."""
+ common = {
+ "authorized_corporate_entity_ids": (),
+ "authorized_process_unit_ids": (),
+ "source_context_required": True,
+ }
+ await load_voice_taxonomy_summary(conn, **common)
+ await load_voice_taxonomy_summary(conn, **common, date_from=date.min)
+ await load_voice_taxonomy_summary(
+ conn,
+ **common,
+ team_id="00000000-0000-0000-0000-000000000000",
+ )
+
+
+async def load_voice_taxonomy_summary(
+ conn: _Connection,
+ *,
+ authorized_corporate_entity_ids: tuple[str, ...],
+ authorized_process_unit_ids: tuple[str, ...],
+ date_from: Any = None,
+ date_to: Any = None,
+ corporate_entity_id: str | None = None,
+ process_unit_id: str | None = None,
+ team_id: str | None = None,
+ person_id: str | None = None,
+ product_catalog_id: str | None = None,
+ project_key: str | None = None,
+ excluded_corporate_entity_ids: tuple[str, ...] = (),
+ source_context_required: bool | None = None,
+) -> dict[str, Any]:
+ """Count overlapping voice memberships over one authorized denominator."""
+ if source_context_required is not None and any(
+ value is not None for value in (team_id, person_id, product_catalog_id, project_key)
+ ):
+ projection = await conn.fetchrow(
+ """
+ select count(*) as total_eligible,
+ count(*) filter (where membership_count = 1) as classified_unique,
+ count(*) filter (where membership_count > 1) as multi_membership,
+ count(*) filter (where has_source) as source_count,
+ count(*) filter (where has_derived) as derived_count,
+ count(*) filter (where membership_count = 0) as unavailable,
+ count(*) filter (where disagreement) as disagreement,
+ jsonb_strip_nulls(jsonb_build_object(
+ 'voc', nullif(count(*) filter (where 'voc' = any(voice_concept_codes)), 0),
+ 'vocc', nullif(count(*) filter (where 'vocc' = any(voice_concept_codes)), 0),
+ 'voco', nullif(count(*) filter (where 'voco' = any(voice_concept_codes)), 0),
+ 'vom', nullif(count(*) filter (where 'vom' = any(voice_concept_codes)), 0),
+ 'vop', nullif(count(*) filter (where 'vop' = any(voice_concept_codes)), 0),
+ 'vos', nullif(count(*) filter (where 'vos' = any(voice_concept_codes)), 0),
+ 'voe', nullif(count(*) filter (where 'voe' = any(voice_concept_codes)), 0),
+ 'vob', nullif(count(*) filter (where 'vob' = any(voice_concept_codes)), 0),
+ 'vor', nullif(count(*) filter (where 'vor' = any(voice_concept_codes)), 0),
+ 'voi', nullif(count(*) filter (where 'voi' = any(voice_concept_codes)), 0),
+ 'voso', nullif(count(*) filter (where 'voso' = any(voice_concept_codes)), 0),
+ 'vops', nullif(count(*) filter (where 'vops' = any(voice_concept_codes)), 0)
+ )) as category_post_counts,
+ coalesce(bool_or(next_transition_at <= current_timestamp), false)
+ as projection_stale
+ from voice_taxonomy_post_read_projection projection
+ where ($3::date is null or projection.event_date >= $3)
+ and ($4::date is null or projection.event_date <= $4)
+ and ($5::uuid is null or projection.corporate_entity_id = $5)
+ and ($6::uuid is null or projection.process_unit_id = $6)
+ and (projection.visibility_code = 'public'
+ or (projection.corporate_entity_id = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or projection.process_unit_id = any($2::uuid[]))))
+ and not (projection.corporate_entity_id = any($11::uuid[]))
+ and (not $12::boolean or projection.source_context_present)
+ and ($7::uuid is null or exists (
+ select 1 from post_team_mention team
+ where team.post_id = projection.post_id and team.team_id = $7))
+ and ($8::uuid is null or exists (
+ select 1 from post_person_mention person
+ where person.post_id = projection.post_id and person.person_id = $8))
+ and ($9::uuid is null or exists (
+ select 1 from post_product_mention product
+ where product.post_id = projection.post_id
+ and product.product_catalog_id = $9))
+ and ($10::text is null or exists (
+ select 1 from post_project_mention project
+ where project.post_id = projection.post_id and project.project_key = $10))
+ """,
+ list(authorized_corporate_entity_ids), list(authorized_process_unit_ids),
+ date_from, date_to, corporate_entity_id, process_unit_id, team_id,
+ person_id, product_catalog_id, project_key,
+ list(excluded_corporate_entity_ids), source_context_required,
+ )
+ if not projection["projection_stale"]:
+ return {
+ key: value for key, value in dict(projection).items()
+ if key != "projection_stale"
+ }
+
+ if (
+ source_context_required is not None
+ and team_id is None
+ and person_id is None
+ and product_catalog_id is None
+ and project_key is None
+ ):
+ projection_relation = (
+ "voice_taxonomy_month_read_projection"
+ if date_from is None and date_to is None
+ else "voice_taxonomy_day_read_projection"
+ )
+ date_predicate = (
+ "$3::date is null and $4::date is null"
+ if date_from is None and date_to is None
+ else "($3::date is null or projection.event_date >= $3) "
+ "and ($4::date is null or projection.event_date <= $4)"
+ )
+ projection = await conn.fetchrow(
+ f"""
+ select coalesce(sum(total_eligible), 0)::bigint as total_eligible,
+ coalesce(sum(classified_unique), 0)::bigint as classified_unique,
+ coalesce(sum(multi_membership), 0)::bigint as multi_membership,
+ coalesce(sum(source_count), 0)::bigint as source_count,
+ coalesce(sum(derived_count), 0)::bigint as derived_count,
+ coalesce(sum(unavailable), 0)::bigint as unavailable,
+ coalesce(sum(disagreement), 0)::bigint as disagreement,
+ jsonb_strip_nulls(jsonb_build_object(
+ 'voc', sum((category_post_counts ->> 'voc')::bigint),
+ 'vocc', sum((category_post_counts ->> 'vocc')::bigint),
+ 'voco', sum((category_post_counts ->> 'voco')::bigint),
+ 'vom', sum((category_post_counts ->> 'vom')::bigint),
+ 'vop', sum((category_post_counts ->> 'vop')::bigint),
+ 'vos', sum((category_post_counts ->> 'vos')::bigint),
+ 'voe', sum((category_post_counts ->> 'voe')::bigint),
+ 'vob', sum((category_post_counts ->> 'vob')::bigint),
+ 'vor', sum((category_post_counts ->> 'vor')::bigint),
+ 'voi', sum((category_post_counts ->> 'voi')::bigint),
+ 'voso', sum((category_post_counts ->> 'voso')::bigint),
+ 'vops', sum((category_post_counts ->> 'vops')::bigint)
+ )) as category_post_counts,
+ coalesce(bool_or(next_transition_at <= current_timestamp), false)
+ as projection_stale
+ from {projection_relation} projection
+ where {date_predicate}
+ and ($5::uuid is null or projection.corporate_entity_id = $5)
+ and ($6::uuid is null or projection.process_unit_key = $6)
+ and (projection.visibility_code = 'public'
+ or (projection.corporate_entity_id = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or projection.process_unit_key = any($2::uuid[]))))
+ and not (projection.corporate_entity_id = any($7::uuid[]))
+ and (not $8::boolean or projection.source_context_present)
+ """,
+ list(authorized_corporate_entity_ids),
+ list(authorized_process_unit_ids),
+ date_from,
+ date_to,
+ corporate_entity_id,
+ process_unit_id,
+ list(excluded_corporate_entity_ids),
+ source_context_required,
+ )
+ if not projection["projection_stale"]:
+ return {
+ key: value for key, value in dict(projection).items()
+ if key != "projection_stale"
+ }
+
+ row = await conn.fetchrow(
+ f"""
+ with eligible as (
+ select post.post_id
+ from source_post post
+ where {source_post_eligibility_sql('post', source_context_required=source_context_required)}
+ and (post.visibility_code = 'public'
+ or (post.corporate_entity_id = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or post.process_unit_id = any($2::uuid[]))))
+ and ($3::date is null or timezone('Asia/Seoul', coalesce(post.event_occurred_at, post.created_at))::date >= $3)
+ and ($4::date is null or timezone('Asia/Seoul', coalesce(post.event_occurred_at, post.created_at))::date <= $4)
+ and ($5::uuid is null or post.corporate_entity_id = $5)
+ and ($6::uuid is null or post.process_unit_id = $6)
+ and ($7::uuid is null or exists (
+ select 1 from post_team_mention team
+ where team.post_id = post.post_id and team.team_id = $7))
+ and ($8::uuid is null or exists (
+ select 1 from post_person_mention person
+ where person.post_id = post.post_id and person.person_id = $8))
+ and ($9::uuid is null or exists (
+ select 1 from post_product_mention product
+ where product.post_id = post.post_id and product.product_catalog_id = $9))
+ and ($10::text is null or exists (
+ select 1 from post_project_mention project
+ where project.post_id = post.post_id and project.project_key = $10))
+ and not (post.corporate_entity_id = any($11::uuid[]))
+ ), memberships as (
+ select assertion.post_id, assertion.assertion_status_code,
+ assertion.voice_concept_code
+ from post_voice_classification_assertion assertion
+ join eligible on eligible.post_id = assertion.post_id
+ where (assertion.valid_from is null or assertion.valid_from <= current_timestamp)
+ and (assertion.valid_to is null or assertion.valid_to > current_timestamp)
+ ), per_post as (
+ select eligible.post_id,
+ count(distinct memberships.voice_concept_code) as membership_count,
+ bool_or(memberships.assertion_status_code = 'source') as has_source,
+ bool_or(memberships.assertion_status_code = 'derived') as has_derived
+ from eligible left join memberships on memberships.post_id = eligible.post_id
+ group by eligible.post_id
+ ), conflicts as (
+ select post_id
+ from memberships
+ group by post_id
+ having bool_or(assertion_status_code = 'source')
+ and bool_or(assertion_status_code = 'derived')
+ and array_agg(distinct voice_concept_code order by voice_concept_code)
+ filter (where assertion_status_code = 'source')
+ is distinct from
+ array_agg(distinct voice_concept_code order by voice_concept_code)
+ filter (where assertion_status_code = 'derived')
+ ), categories as (
+ select voice_concept_code, count(distinct post_id) as post_count
+ from memberships group by voice_concept_code
+ )
+ select count(*) as total_eligible,
+ count(*) filter (where membership_count = 1) as classified_unique,
+ count(*) filter (where membership_count > 1) as multi_membership,
+ count(*) filter (where coalesce(has_source, false)) as source_count,
+ count(*) filter (where coalesce(has_derived, false)) as derived_count,
+ count(*) filter (where membership_count = 0) as unavailable,
+ (select count(*) from conflicts) as disagreement,
+ coalesce((select jsonb_object_agg(voice_concept_code, post_count)
+ from categories), '{{}}'::jsonb) as category_post_counts
+ from per_post
+ """,
+ list(authorized_corporate_entity_ids),
+ list(authorized_process_unit_ids),
+ date_from,
+ date_to,
+ corporate_entity_id,
+ process_unit_id,
+ team_id,
+ person_id,
+ product_catalog_id,
+ project_key,
+ list(excluded_corporate_entity_ids),
+ )
+ return dict(row)
diff --git a/backend/app/voice_taxonomy_transition_worker.py b/backend/app/voice_taxonomy_transition_worker.py
new file mode 100644
index 000000000..eca186a53
--- /dev/null
+++ b/backend/app/voice_taxonomy_transition_worker.py
@@ -0,0 +1,56 @@
+"""Wake the Voice read projection at its persisted validity transition."""
+
+from __future__ import annotations
+
+import asyncio
+
+import asyncpg
+
+_CHANNEL = "voice_taxonomy_transition"
+
+
+async def run_voice_taxonomy_transition_worker(database_url: str) -> None:
+ """Reconcile due assertions without an arbitrary polling interval."""
+ connection = await asyncpg.connect(
+ database_url,
+ server_settings={"jit": "off"},
+ )
+ wake = asyncio.Event()
+
+ def notify(
+ _connection: asyncpg.Connection,
+ _process_id: int,
+ _channel: str,
+ _payload: str,
+ ) -> None:
+ """Wake the worker after a projection transition notification."""
+ wake.set()
+
+ await connection.add_listener(_CHANNEL, notify)
+ try:
+ while True:
+ await connection.fetchval(
+ "select reconcile_due_voice_taxonomy_read_projections()"
+ )
+ wake.clear()
+ delay = await connection.fetchval(
+ """
+ select extract(epoch from
+ min(next_transition_at) - clock_timestamp())::double precision
+ from voice_taxonomy_post_read_projection
+ where next_transition_at is not null
+ """
+ )
+ if delay is None:
+ await wake.wait()
+ elif delay <= 0:
+ continue
+ else:
+ try:
+ await asyncio.wait_for(wake.wait(), timeout=delay)
+ except TimeoutError:
+ # Reaching the scheduled transition is the expected wake-up path.
+ continue
+ finally:
+ await connection.remove_listener(_CHANNEL, notify)
+ await connection.close()
diff --git a/backend/app/worker.py b/backend/app/worker.py
new file mode 100644
index 000000000..fc6a3ec12
--- /dev/null
+++ b/backend/app/worker.py
@@ -0,0 +1,267 @@
+"""Dedicated durable-queue worker process for the Compose deployment."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from urllib.parse import urlsplit
+
+import asyncpg
+
+from backend.app.activity_stream import create_valkey_client
+from backend.app.analysis_run_start import configured_tepp_client
+from backend.app.analysis_run_worker import run_analysis_run_worker
+from backend.app.config import load_settings
+from backend.app.db import create_pool
+from backend.app.global_ask_queue import run_global_ask_worker
+from backend.app.main import (
+ _adjudication_client,
+ _claim_verification_client_factory,
+ _embedding_client,
+ _post_chat_client,
+ _post_structure_client,
+ _semantic_query_client,
+ _vision_client,
+)
+from backend.app.post_content_worker import run_post_content_worker
+from backend.app.topic_influence_worker import run_topic_influence_worker
+from backend.app.voice_taxonomy_transition_worker import (
+ run_voice_taxonomy_transition_worker,
+)
+from backend.app.worker_health import run_worker_heartbeat
+from lineageweave.observability import configure_telemetry, shutdown_telemetry
+from lineageweave.topic_influence_client import HttpTopicInfluenceClient
+
+_ALL_CONSUMERS = (
+ "analysis_run",
+ "post_content",
+ "global_ask",
+ "voice_taxonomy",
+ "topic_influence",
+)
+_logger = logging.getLogger(__name__)
+
+
+def _selected_consumers(settings: object) -> frozenset[str]:
+ """Return the declared consumer set, defaulting to the historical full worker."""
+ raw = getattr(settings, "worker_consumers", "")
+ if not isinstance(raw, str):
+ raise TypeError("LINEAGEWEAVE_WORKER_CONSUMERS must be comma-separated text")
+ selected = frozenset(item.strip() for item in raw.split(",") if item.strip())
+ if not selected:
+ return frozenset(_ALL_CONSUMERS)
+ unknown = selected.difference(_ALL_CONSUMERS)
+ if unknown:
+ raise ValueError(
+ "LINEAGEWEAVE_WORKER_CONSUMERS contains unknown consumers: "
+ + ", ".join(sorted(unknown))
+ )
+ return selected
+
+
+def _active_consumers(
+ selected: frozenset[str], *, topic_influence_enabled: bool
+) -> frozenset[str]:
+ """Remove an unavailable optional consumer and reject a no-op worker."""
+ active = selected.difference(() if topic_influence_enabled else {"topic_influence"})
+ if not active:
+ raise ValueError("selected worker has no active durable consumers")
+ return active
+
+
+def _topic_influence_timeouts(settings: object) -> tuple[int, int, int]:
+ """Return a declared request/lease pair with persistence time remaining."""
+ request_timeout = getattr(
+ settings, "topic_influence_request_timeout_seconds", None
+ )
+ lease_timeout = getattr(settings, "topic_influence_lease_timeout_seconds", None)
+ poll_seconds = getattr(settings, "topic_influence_poll_seconds", None)
+ if (
+ type(request_timeout) is not int
+ or type(lease_timeout) is not int
+ or request_timeout <= 0
+ or lease_timeout <= request_timeout
+ or type(poll_seconds) is not int
+ or poll_seconds <= 0
+ ):
+ raise ValueError(
+ "topic influence lease timeout must be a declared positive integer "
+ "strictly greater than the declared positive request timeout, with a "
+ "declared positive poll interval"
+ )
+ return request_timeout, lease_timeout, poll_seconds
+
+
+def _optional_topic_influence_timeouts(
+ settings: object, *, transport_url: object
+) -> tuple[int, int, int] | None:
+ """Disable only optional influence work when its endpoint contract is invalid."""
+ if not transport_url:
+ return None
+ if not isinstance(transport_url, str):
+ _logger.error(
+ "Topic influence is disabled; declare an absolute HTTP or HTTPS "
+ "transport URL before enabling this consumer"
+ )
+ return None
+ parsed = urlsplit(transport_url)
+ if (
+ parsed.scheme not in {"http", "https"}
+ or not parsed.netloc
+ or not parsed.hostname
+ ):
+ _logger.error(
+ "Topic influence is disabled; declare an absolute HTTP or HTTPS "
+ "transport URL before enabling this consumer"
+ )
+ return None
+ try:
+ return _topic_influence_timeouts(settings)
+ except ValueError:
+ _logger.error(
+ "Topic influence is disabled; declare a positive lease timeout strictly "
+ "greater than its request timeout before enabling this consumer"
+ )
+ return None
+
+
+@asynccontextmanager
+async def _consumer_worker_lease(
+ pool: asyncpg.Pool, consumers: frozenset[str]
+) -> AsyncIterator[None]:
+ """Hold one session-level advisory lease for every selected consumer."""
+ async with pool.acquire() as conn:
+ acquired: list[str] = []
+ try:
+ for consumer in sorted(consumers):
+ lease_name = f"lineageweave_durable_queue_worker:{consumer}"
+ owns_lease = bool(
+ await conn.fetchval(
+ "select pg_try_advisory_lock(hashtextextended($1, 0))",
+ lease_name,
+ )
+ )
+ if not owns_lease:
+ raise RuntimeError(
+ f"another durable queue worker already owns {consumer}"
+ )
+ acquired.append(lease_name)
+ yield
+ finally:
+ for lease_name in reversed(acquired):
+ await conn.fetchval(
+ "select pg_advisory_unlock(hashtextextended($1, 0))",
+ lease_name,
+ )
+
+
+async def run_worker_process() -> None:
+ """Own every durable queue consumer outside the HTTP API process."""
+ configure_telemetry("lineageweave-worker")
+ settings = load_settings()
+ pool = await create_pool(settings.database_url)
+ valkey = create_valkey_client(settings.valkey_url)
+ try:
+ selected = _selected_consumers(settings)
+ topic_influence_url = getattr(settings, "topic_influence_transport_url", "")
+ influence_timeouts = _optional_topic_influence_timeouts(
+ settings, transport_url=topic_influence_url
+ )
+ active_consumers = _active_consumers(
+ selected,
+ topic_influence_enabled=bool(
+ topic_influence_url and influence_timeouts is not None
+ ),
+ )
+ async with _consumer_worker_lease(pool, frozenset(active_consumers)):
+ workers = [asyncio.create_task(run_worker_heartbeat())]
+ if "analysis_run" in active_consumers:
+ workers.append(
+ asyncio.create_task(
+ run_analysis_run_worker(
+ valkey,
+ pool,
+ database_url=settings.database_url,
+ tepp_client=configured_tepp_client(
+ settings.tepp_transport_url,
+ settings.tepp_api_key,
+ ),
+ adjudication_client=_adjudication_client(),
+ )
+ )
+ )
+ if "post_content" in active_consumers:
+ workers.append(
+ asyncio.create_task(
+ run_post_content_worker(
+ valkey,
+ pool,
+ vision_factory=_vision_client,
+ embedding_factory=_embedding_client,
+ structure_factory=_post_structure_client,
+ )
+ )
+ )
+ if "global_ask" in active_consumers:
+ workers.append(
+ asyncio.create_task(
+ run_global_ask_worker(
+ valkey,
+ pool,
+ chat_factory=lambda: _post_chat_client(
+ timeout=load_settings().orchestrator_answer_timeout_seconds
+ ),
+ embedding_factory=_embedding_client,
+ semantic_query_factory=_semantic_query_client,
+ claim_verification_factory=_claim_verification_client_factory,
+ )
+ )
+ )
+ if "voice_taxonomy" in active_consumers:
+ workers.append(
+ asyncio.create_task(
+ run_voice_taxonomy_transition_worker(settings.database_url)
+ )
+ )
+ if "topic_influence" in active_consumers:
+ assert influence_timeouts is not None
+ request_timeout, lease_timeout, poll_seconds = influence_timeouts
+ workers.append(
+ asyncio.create_task(
+ run_topic_influence_worker(
+ pool,
+ lambda: HttpTopicInfluenceClient(
+ topic_influence_url,
+ getattr(settings, "topic_influence_api_key", ""),
+ timeout=float(request_timeout),
+ lease_timeout_seconds=lease_timeout,
+ ),
+ poll_seconds=float(poll_seconds),
+ )
+ )
+ )
+ try:
+ await asyncio.gather(*workers)
+ finally:
+ for worker in workers:
+ worker.cancel()
+ await asyncio.gather(*workers, return_exceptions=True)
+ finally:
+ try:
+ await pool.close()
+ finally:
+ try:
+ await valkey.aclose()
+ finally:
+ shutdown_telemetry()
+
+
+def main() -> None:
+ """Run the durable worker service until Compose stops the process."""
+ asyncio.run(run_worker_process())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/app/worker_health.py b/backend/app/worker_health.py
new file mode 100644
index 000000000..019bbce3f
--- /dev/null
+++ b/backend/app/worker_health.py
@@ -0,0 +1,120 @@
+"""Progress-based health contract for the durable worker event loop."""
+
+from __future__ import annotations
+
+import asyncio
+import fcntl
+from pathlib import Path
+import tempfile
+import threading
+import time
+import uuid
+
+
+HEARTBEAT_PATH = Path("/tmp/lineageweave-worker-heartbeat")
+HEALTHCHECK_STATE_PATH = Path("/tmp/lineageweave-worker-healthcheck-state")
+_SAMPLE_VERSION = "v1"
+_MAX_MONOTONIC_COUNTER = (1 << 63) - 1
+_PROBE_THREAD_LOCK = threading.Lock()
+
+
+def _parse_sample(value: str) -> tuple[str, int] | None:
+ parts = value.split()
+ if len(parts) != 3 or parts[0] != _SAMPLE_VERSION:
+ return None
+ epoch, counter_text = parts[1:]
+ if (
+ len(epoch) != 32
+ or any(character not in "0123456789abcdef" for character in epoch)
+ or not counter_text.isascii()
+ or not counter_text.isdecimal()
+ ):
+ return None
+ if len(counter_text) > 19:
+ return None
+ counter = int(counter_text)
+ if counter > _MAX_MONOTONIC_COUNTER:
+ return None
+ return epoch, counter
+
+
+def record_worker_heartbeat(path: Path = HEARTBEAT_PATH, *, epoch: str) -> None:
+ """Record one monotonic event-loop progress sample atomically."""
+ if _parse_sample(f"{_SAMPLE_VERSION} {epoch} 0") is None:
+ raise ValueError("worker heartbeat epoch must be 32 lowercase hex characters")
+ temporary = path.with_suffix(".tmp")
+ temporary.write_text(
+ f"{_SAMPLE_VERSION} {epoch} {time.monotonic_ns()}\n", encoding="ascii"
+ )
+ temporary.replace(path)
+
+
+async def run_worker_heartbeat(
+ path: Path = HEARTBEAT_PATH,
+ *,
+ state_path: Path = HEALTHCHECK_STATE_PATH,
+ epoch: str | None = None,
+) -> None:
+ """Record progress once per broker-poll interval until cancelled."""
+ process_epoch = epoch or uuid.uuid4().hex
+ # A restarted container can retain /tmp while the host monotonic clock has
+ # restarted from zero. Begin a fresh comparison epoch before publishing.
+ path.unlink(missing_ok=True)
+ state_path.unlink(missing_ok=True)
+ while True:
+ record_worker_heartbeat(path, epoch=process_epoch)
+ await asyncio.sleep(1.0)
+
+
+def heartbeat_has_advanced(
+ heartbeat_path: Path = HEARTBEAT_PATH,
+ state_path: Path = HEALTHCHECK_STATE_PATH,
+) -> bool:
+ """Return whether the heartbeat advanced since the prior health probe."""
+ lock_path = state_path.with_name(f".{state_path.name}.lock")
+ try:
+ with _PROBE_THREAD_LOCK, lock_path.open("a+b") as lock_file:
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
+ return _heartbeat_has_advanced_locked(heartbeat_path, state_path)
+ except (OSError, UnicodeDecodeError):
+ return False
+
+
+def _heartbeat_has_advanced_locked(heartbeat_path: Path, state_path: Path) -> bool:
+ try:
+ current_text = heartbeat_path.read_text(encoding="ascii")
+ except FileNotFoundError:
+ return False
+ current = _parse_sample(current_text)
+ if current is None:
+ return False
+ try:
+ previous = _parse_sample(state_path.read_text(encoding="ascii"))
+ except FileNotFoundError:
+ previous = None
+ else:
+ if previous is None:
+ return False
+ with tempfile.NamedTemporaryFile(
+ mode="w",
+ encoding="ascii",
+ dir=state_path.parent,
+ prefix=f".{state_path.name}.",
+ delete=False,
+ ) as temporary_file:
+ temporary_file.write(current_text)
+ temporary = Path(temporary_file.name)
+ try:
+ temporary.replace(state_path)
+ finally:
+ temporary.unlink(missing_ok=True)
+ return previous is None or current[0] != previous[0] or current[1] > previous[1]
+
+
+def main() -> None:
+ """Exit successfully only when the durable worker event loop progressed."""
+ raise SystemExit(0 if heartbeat_has_advanced() else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 520277031..03faf2d15 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -35,7 +35,7 @@
"LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave"
)
_KEYCLOAK_BASE_URL = os.environ.get("LINEAGEWEAVE_TEST_KEYCLOAK_BASE_URL", "http://localhost:18080")
-_VALKEY_URL = os.environ.get("LINEAGEWEAVE_TEST_VALKEY_URL", "redis://localhost:16379/0")
+_VALKEY_URL = os.environ.get("LINEAGEWEAVE_TEST_VALKEY_URL", "redis://localhost:16379/15")
_REALM = "lineageweave-demo"
_MIGRATION_PATH = Path(__file__).resolve().parents[2] / "migrations" / "0001_initial_schema.sql"
_REGISTRY_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0018_analysis_run_registry.sql"
@@ -217,11 +217,94 @@
/ "migrations"
/ "0218_global_ask_public_verification.sql"
)
+_SOURCE_RESEARCH_CITATION_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0236_source_research_citation.sql"
+)
_GLOBAL_ASK_KNOWLEDGE_CUTOFF_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
/ "0212_global_ask_knowledge_cutoff.sql"
)
+_LATE_REPLAYABLE_MIGRATIONS = tuple(
+ Path(__file__).resolve().parents[2] / "migrations" / name
+ for name in (
+ "0017_prov_o_standard_relations.sql",
+ "0175_ontology_truth_status.sql",
+ "0208_operations_case_analysis.sql",
+ "0209_operations_case_evidence_source.sql",
+ "0217_analysis_run_tepp_receipt.sql",
+ "0233_source_conversation_turn_evidence.sql",
+ "0233_report_leftover_map_unexplained_share.sql",
+ "0235_voice_of_x_post_taxonomy.sql",
+ "0237_source_post_voice_combination.sql",
+ "0238_occupational_construct_assertion.sql",
+ "0239_occupational_construct_catalog.sql",
+ "0240_occupational_construct_extraction_run.sql",
+ "0241_occupational_construct_ontology_navigation.sql",
+ "0242_occupational_construct_catalog_search.sql",
+ "0243_source_post_voice_history.sql",
+ "0244_report_leftover_map_explained_share.sql",
+ "0245_operations_case_missing_fact.sql",
+ "0246_operations_external_relation_target.sql",
+ "0247_topic_context_influence_projection.sql",
+ "0248_operations_case_milestone.sql",
+ "0250_operations_case_analysis_input.sql",
+ "0251_product_semantic_catalog.sql",
+ "0253_voice_semantic_taxonomy.sql",
+ "0257_public_claim_envelope.sql",
+ "0263_voice_taxonomy_read_projection.sql",
+ "0271_derived_voice_classification_analysis.sql",
+ "0272_product_analysis_model_receipt.sql",
+ )
+)
+
+
+def _run_global_ask_once(client, job_id: str) -> None:
+ """Run one dedicated-worker Ask delivery through the TestClient event loop."""
+ from backend.app import main
+ from backend.app.global_ask_queue import process_global_ask_job
+
+ async def _settle() -> None:
+ await process_global_ask_job(
+ client.app.state.pool,
+ job_id=job_id,
+ chat_factory=lambda: main._post_chat_client(
+ timeout=main.load_settings().orchestrator_answer_timeout_seconds
+ ),
+ embedding_factory=main._embedding_client,
+ semantic_query_factory=main._semantic_query_client,
+ claim_verification_factory=main._claim_verification_client_factory,
+ )
+
+ client.portal.call(_settle)
+
+
+def test_dashboard_external_query_reaches_projection(
+ client, demo_analyst_token, monkeypatch
+) -> None:
+ """The external-information navigation must request an external-only projection."""
+ observed: list[bool] = []
+
+ async def _fake_dashboard(
+ _conn, _corporate_entity_ids, _process_unit_ids,
+ _period_start=None, _period_end=None, external_only=False,
+ _source_context_required=None, _case_cursor=None, _case_limit=20,
+ ):
+ observed.append(external_only)
+ return {"external_only": external_only}
+
+ monkeypatch.setattr("backend.app.main.fetch_operations_dashboard", _fake_dashboard)
+ response = client.get(
+ "/api/dashboard?external_only=true",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200
+ assert response.json() == {"external_only": True}
+ assert observed == [True]
+
+
_LEFTOVER_MAP_AXIS_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
@@ -247,6 +330,26 @@
/ "migrations"
/ "0183_source_post_event_occurred_at.sql"
)
+_CUSTOMER_MASTER_READ_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0266_customer_master_group_read_projection.sql"
+)
+_DASHBOARD_READ_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0264_dashboard_post_read_projection.sql"
+)
+_POST_LIST_READ_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0265_post_list_read_projection_index.sql"
+)
+_POST_SEARCH_READ_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0269_post_body_search_read_projection.sql"
+)
def _postgres_available() -> bool:
@@ -288,6 +391,21 @@ def _valkey_available() -> bool:
)
+@pytest.fixture(scope="session", autouse=True)
+def isolated_valkey_database():
+ """Keep integration-test streams outside the canonical runtime database."""
+ if not _valkey_available():
+ yield
+ return
+ client = redis.from_url(_VALKEY_URL)
+ client.flushdb()
+ try:
+ yield
+ finally:
+ client.flushdb()
+ client.close()
+
+
def _fetch_demo_analyst_token() -> str:
"""Request a real resource-owner token for the synthetic demo.analyst user."""
token_response = post_form(
@@ -413,10 +531,53 @@ def seeded_db(demo_analyst_token):
],
check=True,
)
+ cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text())
+ # The Dashboard projection is migration 0264 and therefore reads
+ # the case-analysis schema established by these earlier replayable
+ # migrations. Keep the throwaway fixture in production migration
+ # order; the full replay below remains the idempotency check.
+ for migration_path in _LATE_REPLAYABLE_MIGRATIONS:
+ if migration_path.name in {
+ "0017_prov_o_standard_relations.sql",
+ "0208_operations_case_analysis.sql",
+ "0209_operations_case_evidence_source.sql",
+ "0245_operations_case_missing_fact.sql",
+ "0246_operations_external_relation_target.sql",
+ "0247_topic_context_influence_projection.sql",
+ "0248_operations_case_milestone.sql",
+ "0250_operations_case_analysis_input.sql",
+ }:
+ cur.execute(migration_path.read_text())
+ cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text())
+ subprocess.run(
+ [
+ "psql", "-X", "-v", "ON_ERROR_STOP=1", db_dsn,
+ "-f", str(_DASHBOARD_READ_MIGRATION),
+ ],
+ check=True,
+ )
+ subprocess.run(
+ [
+ "psql", "-X", "-v", "ON_ERROR_STOP=1", db_dsn,
+ "-f", str(_POST_LIST_READ_MIGRATION),
+ "-f", str(_POST_SEARCH_READ_MIGRATION),
+ ],
+ check=True,
+ )
+ subprocess.run(
+ [
+ "psql", "-X", "-v", "ON_ERROR_STOP=1", db_dsn,
+ "-f", str(_CUSTOMER_MASTER_READ_MIGRATION),
+ ],
+ check=True,
+ )
conn.autocommit = False
cur.execute(_GLOBAL_ASK_KNOWLEDGE_CUTOFF_MIGRATION.read_text())
cur.execute(_GLOBAL_ASK_PUBLIC_VERIFICATION_MIGRATION.read_text())
+ cur.execute(_SOURCE_RESEARCH_CITATION_MIGRATION.read_text())
cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text())
+ for migration_path in _LATE_REPLAYABLE_MIGRATIONS:
+ cur.execute(migration_path.read_text())
cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text())
cur.execute(_CHANNEL_EVIDENCE_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text())
@@ -813,6 +974,7 @@ def _insert_post(
@pytest.fixture
def client(seeded_db):
os.environ["DATABASE_URL"] = seeded_db["dsn"]
+ os.environ["VALKEY_URL"] = _VALKEY_URL
os.environ["KEYCLOAK_BASE_URL"] = _KEYCLOAK_BASE_URL
os.environ["KEYCLOAK_ISSUER"] = f"{_KEYCLOAK_BASE_URL}/realms/{_REALM}"
@@ -1088,7 +1250,7 @@ def test_create_analysis_run_records_pending_without_inventing_a_score(
},
)
assert tepp.status_code == 422
- assert "invent a measurement" in tepp.json()["detail"]
+ assert "restore analysis" in tepp.json()["detail"]
assert "theta" not in tepp.json()["detail"].lower()
report = client.post(
@@ -1247,7 +1409,7 @@ def test_start_analysis_run_recovers_the_a100_fork(
},
)
assert tepp_create.status_code == 422
- assert "invent a measurement" in tepp_create.json()["detail"]
+ assert "restore analysis" in tepp_create.json()["detail"]
admin_conn = psycopg2.connect(seeded_db["dsn"])
admin_conn.autocommit = True
@@ -1425,7 +1587,7 @@ def test_start_analysis_run_recovers_the_a100_fork(
headers={"Authorization": f"Bearer {demo_analyst_token}"},
)
assert report_refused.status_code == 422
- assert "invent a measurement" in report_refused.json()["detail"]
+ assert report_refused.json()["detail"] == "기간 보고서 화면에서 다시 계산하세요."
running = client.post(
f"/api/analysis-runs/{running_run_id}/start",
@@ -1575,62 +1737,27 @@ def test_update_me_preferences_requires_authentication(client) -> None:
assert response.status_code in (401, 403)
-def test_rankings_fail_closed_payload_is_exact(
+def test_rankings_without_selection_lists_only_persisted_context_choices(
client, demo_analyst_token, seeded_db, monkeypatch
) -> None:
- """A missing RankWeave transport is unavailable, never ambiguous success."""
- from lineageweave.rankweave_client import RankWeaveClient
-
- monkeypatch.setattr("backend.app.main._rankweave_client", RankWeaveClient)
+ """An unselected read never fabricates a default ranking channel."""
response = client.get("/api/rankings", headers={"Authorization": f"Bearer {demo_analyst_token}"})
assert response.status_code == 200
assert response.json() == {
- "port": "rankweave",
- "status": "unavailable",
- "status_reason": "rankweave_not_available",
+ "status": "selection_required",
+ "context_choices": [],
"rankings": [],
}
-def test_rankings_accept_only_abac_visible_posts(
+def test_rankings_reject_partial_or_extra_selection(
client, demo_analyst_token, seeded_db, monkeypatch
) -> None:
- """A deterministic RankWeave adapter ranks visible synthetic posts only."""
- from lineageweave.rankweave_client import RankWeaveClient
-
- captured_channels: dict[str, list[str]] = {}
-
- def fuse_visible(
- channels: dict[str, list[str]], _weights: dict[str, float]
- ) -> list[dict[str, str]]:
- captured_channels.update(channels)
- return [{"item_id": post_id} for post_id in channels["temporal"]]
-
- monkeypatch.setattr(
- "backend.app.main._rankweave_client",
- lambda: RankWeaveClient(transport=fuse_visible),
- )
- response = client.get(
- "/api/rankings",
- headers={"Authorization": f"Bearer {demo_analyst_token}"},
- )
-
- assert response.status_code == 200
- body = response.json()
- assert body["port"] == "rankweave"
- assert body["status"] == "accepted"
- assert body["status_reason"] is None
- assert [row["fused_rank"] for row in body["rankings"]] == list(
- range(1, len(body["rankings"]) + 1)
- )
- visible_by_id = {row["post_id"]: row for row in body["rankings"]}
- assert visible_by_id[seeded_db["own_private_post_id"]]["post_title"] == (
- "Own-corp private post"
- )
- assert visible_by_id[seeded_db["public_post_id"]]["post_title"] == "Public post"
- assert seeded_db["other_private_post_id"] not in visible_by_id
- assert seeded_db["other_private_post_id"] not in captured_channels["temporal"]
- assert "theta" not in str(body).lower()
+ """A ranking requires exactly ADR 0278's five selection keys."""
+ headers = {"Authorization": f"Bearer {demo_analyst_token}"}
+ assert client.get("/api/rankings?topic_index=0", headers=headers).status_code == 422
+ query = "topic_model_run_id=x&influence_run_id=y&topic_index=0&dimension=team&context=z&extra=1"
+ assert client.get(f"/api/rankings?{query}", headers=headers).status_code == 422
def test_rankings_requires_authentication(client) -> None:
@@ -1639,6 +1766,8 @@ def test_rankings_requires_authentication(client) -> None:
assert response.status_code in (401, 403)
+
+
def test_customer_master_returns_authorized_catalog_contract(client, demo_analyst_token, seeded_db) -> None:
admin_conn = psycopg2.connect(seeded_db["dsn"])
try:
@@ -1704,8 +1833,15 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys
body = response.json()
assert set(body) == {
"corporate_entities", "keymen", "source_customer_hints", "source_author_hints",
- "relationship_network",
+ "relationship_network", "source_customer_hint_total",
+ "source_author_hint_total", "next_customer_cursor", "next_author_cursor",
}
+ assert body["source_customer_hint_total"] == 1
+ assert body["source_author_hint_total"] == 1
+ assert body["next_customer_cursor"] is None
+ assert body["next_author_cursor"] is None
+
+
entity = next(item for item in body["corporate_entities"] if item["entity_name"] == "Test Corp")
assert {
"corporate_entity_id", "corporate_entity_code", "entity_name",
@@ -1744,10 +1880,9 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys
"customer_code": "TEST-CUSTOMER-001",
"customer_name": None,
"post_count": 1,
- "related_posts": [{
- "post_id": seeded_db["public_post_id"],
- "post_title": "Public post",
- }],
+ "related_posts": [],
+ "related_posts_next_cursor": None,
+ "related_posts_loaded": False,
"resolution_status": "hint_only",
"hint_trust": "normal",
"provenance": "source_post.source_customer_code/source_post.source_customer_name",
@@ -1769,12 +1904,9 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys
"provenance": "post_person_mention.person_id|post_summary_role.cataloged_person_id/source_post.author_account_id",
}
]
- assert author_hint[0]["related_posts"] == [
- {
- "post_id": seeded_db["public_post_id"],
- "post_title": "Public post",
- }
- ]
+ assert author_hint[0]["related_posts"] == []
+ assert author_hint[0]["related_posts_next_cursor"] is None
+ assert author_hint[0]["related_posts_loaded"] is False
assert author_hint[0]["resolution_status"] == "our_side_context_only"
assert any(
affiliation["entity_name"] == "Test Corp"
@@ -1783,6 +1915,94 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys
assert "account_affiliation.corporate_entity_id" in author_hint[0]["provenance"]
+def test_customer_master_keysets_customer_and_author_groups_independently(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """Bounded pages preserve exact group totals without duplicate continuations."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ for index in range(5):
+ cur.execute(
+ "insert into source_post "
+ "(post_title, post_body, created_at, visibility_code, corporate_entity_id, "
+ " author_account_id, voc_type_code, source_customer_code, source_author_code) "
+ "select %s, %s, now() + (%s || ' seconds')::interval, 'public', %s, "
+ " author_account_id, voc_type_code, %s, %s from source_post where post_id = %s",
+ (
+ f"Synthetic customer page {index}", "Synthetic body", index,
+ seeded_db["own_corp_id"], f"CUSTOMER-{index:02d}",
+ f"AUTHOR-{index:02d}", seeded_db["public_post_id"],
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ headers = {"Authorization": f"Bearer {demo_analyst_token}"}
+ first = client.get("/api/customer-master?hint_limit=2", headers=headers)
+ assert first.status_code == 200
+ first_body = first.json()
+ assert first_body["source_customer_hint_total"] == 5
+ assert first_body["source_author_hint_total"] == 5
+ assert first_body["next_customer_cursor"]
+ assert first_body["next_author_cursor"]
+ second = client.get(
+ "/api/customer-master",
+ params={
+ "hint_limit": 2,
+ "customer_cursor": first_body["next_customer_cursor"],
+ "author_cursor": first_body["next_author_cursor"],
+ },
+ headers=headers,
+ )
+ assert second.status_code == 200
+ second_body = second.json()
+ assert {
+ row["customer_code"] for row in first_body["source_customer_hints"]
+ }.isdisjoint({row["customer_code"] for row in second_body["source_customer_hints"]})
+ assert {
+ row["author_code"] for row in first_body["source_author_hints"]
+ }.isdisjoint({row["author_code"] for row in second_body["source_author_hints"]})
+ assert client.get(
+ "/api/customer-master?customer_cursor=not-a-cursor", headers=headers
+ ).status_code == 422
+
+
+def test_customer_master_related_posts_load_as_an_independent_keyset(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """Opening one group retrieves its exact authorized evidence separately."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "update source_post set source_customer_code = %s where post_id = %s",
+ ("TEST-CUSTOMER-001", seeded_db["public_post_id"]),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ headers = {"Authorization": f"Bearer {demo_analyst_token}"}
+ related = client.get(
+ "/api/customer-master/related-posts",
+ params={"kind": "customer", "customer_code": "TEST-CUSTOMER-001", "limit": 1},
+ headers=headers,
+ )
+ assert related.status_code == 200
+ assert [post["post_id"] for post in related.json()["related_posts"]] == [
+ seeded_db["public_post_id"]
+ ]
+ assert client.get(
+ "/api/customer-master/related-posts",
+ params={
+ "kind": "customer",
+ "customer_code": "TEST-CUSTOMER-001",
+ "cursor": "not-a-cursor",
+ },
+ headers=headers,
+ ).status_code == 422
+
+
def test_resolve_customer_hint_creates_and_links_a_corroborated_entity(
client, demo_analyst_token, seeded_db, monkeypatch
) -> None:
@@ -1903,6 +2123,15 @@ def test_post_list_supports_bounded_offset_pages(client, demo_analyst_token, see
assert title_sorted.status_code == 200, title_sorted.text
assert title_sorted.json()["posts"][0]["post_title"] == "Edited own-corp private post"
+ broad_search = client.get(
+ "/api/posts?search=post&limit=2",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert broad_search.status_code == 200, broad_search.text
+ assert len(broad_search.json()["posts"]) == 2
+ assert broad_search.json()["total_count"] == 4
+ assert all(post["post_title"] != "Other-corp private post" for post in broad_search.json()["posts"])
+
invalid_sort = client.get(
"/api/posts?sort=unsupported",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
@@ -1923,6 +2152,420 @@ def test_post_detail_uses_lookup_labels_not_raw_codes(client, demo_analyst_token
assert body["visibility_label"] == "Public"
+def test_post_detail_returns_authorized_product_evidence(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """The post response exposes only its persisted evidence-bound product link."""
+ from backend.app.post_content_queue import source_body_sha256
+
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "select post_body from source_post where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ current_body_sha256 = source_body_sha256(cur.fetchone()[0])
+ cur.execute(
+ "insert into product_catalog "
+ "(canonical_product_name, product_level_code, product_catalog_code) "
+ "values (%s, %s, %s) returning product_catalog_id",
+ ("Synthetic Model Q", "product_model", "SYNTH-Q"),
+ )
+ catalog_id = cur.fetchone()[0]
+ cur.execute(
+ "insert into post_product_analysis "
+ "(post_id, source_body_sha256, analysis_input_sha256, "
+ "orchestrator_session_id, orchestrator_model_receipt) "
+ "values (%s, %s, %s, %s, %s)",
+ (
+ seeded_db["public_post_id"],
+ current_body_sha256,
+ "b" * 64,
+ "session-a",
+ "product-receipt-a",
+ ),
+ )
+ cur.execute(
+ "insert into post_product_mention "
+ "(post_id, mention_ordinal, product_catalog_id, extracted_product_name, "
+ "resolution_status_code, evidence_text, evidence_post_id, evidence_input_sha256) "
+ "values (%s, 0, %s, %s, 'unique', %s, %s, %s)",
+ (
+ seeded_db["public_post_id"], catalog_id, "Synthetic Model Q",
+ "Synthetic evidence", seeded_db["public_post_id"], "c" * 64,
+ ),
+ )
+ cur.execute(
+ "insert into post_project_mention "
+ "(post_id, project_key, project_name, evidence_text, confidence, "
+ "ontology_iri, extraction_method) values (%s, %s, %s, %s, %s, %s, %s) "
+ "on conflict (post_id, project_key) do nothing",
+ (
+ seeded_db["public_post_id"],
+ "synthetic-product-project",
+ "Synthetic Product Project",
+ "Synthetic evidence",
+ 1,
+ "https://contextualwisdomlab.github.io/LineageWeave/ontology#Project",
+ "synthetic_fixture",
+ ),
+ )
+ cur.execute(
+ "insert into product_project_relation "
+ "(post_id, mention_ordinal, project_key, relation_type_code, "
+ "evidence_text, evidence_post_id, evidence_input_sha256) "
+ "values (%s, 0, %s, 'used_by_project', %s, %s, %s)",
+ (
+ seeded_db["public_post_id"],
+ "synthetic-product-project",
+ "Synthetic evidence",
+ seeded_db["public_post_id"],
+ "c" * 64,
+ ),
+ )
+ cur.execute(
+ "insert into post_product_mention "
+ "(post_id, mention_ordinal, extracted_product_name, "
+ "resolution_status_code, evidence_text, evidence_post_id, "
+ "evidence_input_sha256) "
+ "values (%s, 1, %s, 'missing', %s, %s, %s)",
+ (
+ seeded_db["public_post_id"],
+ "Hidden Synthetic Model",
+ "Hidden synthetic evidence",
+ seeded_db["other_private_post_id"],
+ "d" * 64,
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ response = client.get(
+ f"/api/posts/{seeded_db['public_post_id']}",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200
+ assert response.json()["product_evidence_status"] == {
+ "status_code": "complete",
+ "next_action": "Open the linked products and source evidence.",
+ }
+ assert response.json()["product_evidence"] == [{
+ "mention_ordinal": 0,
+ "extracted_product_name": "Synthetic Model Q",
+ "resolution_status_code": "unique",
+ "canonical_product_name": "Synthetic Model Q",
+ "product_catalog_id": str(catalog_id),
+ "product_catalog_code": "SYNTH-Q",
+ "ontology_iri": (
+ "https://contextualwisdomlab.github.io/LineageWeave/ontology#"
+ f"node/product/{catalog_id}"
+ ),
+ "product_level_code": "product_model",
+ "evidence_text": "Synthetic evidence",
+ "evidence_post_id": seeded_db["public_post_id"],
+ "relations": [{
+ "relation_type_code": "used_by_project",
+ "target_kind_code": "project",
+ "target_id": "project:synthetic-product-project",
+ "target_label": "Synthetic Product Project",
+ "evidence_text": "Synthetic evidence",
+ "evidence_post_id": seeded_db["public_post_id"],
+ }],
+ }]
+
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "update post_product_analysis set orchestrator_model_receipt = null "
+ "where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ unreceipted = client.get(
+ f"/api/posts/{seeded_db['public_post_id']}",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert unreceipted.status_code == 200
+ assert unreceipted.json()["product_evidence"] == []
+ assert unreceipted.json()["product_evidence_status"]["status_code"] == "setup_required"
+
+
+def test_voice_taxonomy_summary_uses_visible_post_denominator(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """Counts include visible unavailable posts and disclose overlap semantics."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute("delete from post_voice_classification_assertion")
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_sha256, "
+ "source_revision_digest) select post_id, 'voc', 'source', repeat('a', 64), "
+ "repeat('b', 64) from source_post"
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ response = client.get(
+ "/api/voice-taxonomy/summary",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200, response.text
+ payload = response.json()
+ assert payload["total_eligible"] == 4
+ assert payload["source_count"] == 4
+ assert payload["counts_overlap"] is True
+ assert payload["category_memberships"] == [{
+ "voice_concept_code": "voc",
+ "post_count": 4,
+ "eligible_percentage": 100.0,
+ }]
+ assert "category_post_counts" not in payload
+
+
+def test_voice_source_ingestion_is_available_for_future_business_event(
+ seeded_db,
+) -> None:
+ """Ingestion records a source label immediately, not at event time."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "delete from post_voice_classification_assertion where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "update source_post set post_body = post_body, "
+ "event_occurred_at = '2999-01-01T00:00:00Z' where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "select classification_assertion_id, valid_from "
+ "from post_voice_classification_assertion "
+ "where post_id = %s and assertion_status_code = 'source' "
+ "and voice_concept_code = 'voc'",
+ (seeded_db["public_post_id"],),
+ )
+ first_assertion_id, valid_from = cur.fetchone()
+ assert valid_from is None
+ cur.execute(
+ "update source_post set post_body = post_body || ' revised' "
+ "where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "update source_post set post_body = post_body where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "select count(*), count(*) filter (where valid_to is null), "
+ "count(*) filter (where classification_assertion_id = %s "
+ "and valid_to is not null), "
+ "max(supersedes_assertion_id::text) filter (where valid_to is null) "
+ "from post_voice_classification_assertion where post_id = %s "
+ "and assertion_status_code = 'source'",
+ (first_assertion_id, seeded_db["public_post_id"]),
+ )
+ assert cur.fetchone() == (
+ 2,
+ 1,
+ 1,
+ str(first_assertion_id),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def test_derived_voice_assertion_requires_model_receipt(seeded_db) -> None:
+ """A derived classification cannot persist without its model receipt."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur, pytest.raises(psycopg2.errors.CheckViolation):
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_span_start, "
+ "evidence_span_end, evidence_sha256, source_revision_digest) "
+ "values (%s, 'voc', 'derived', 0, 1, repeat('a', 64), repeat('b', 64))",
+ (seeded_db["public_post_id"],),
+ )
+ finally:
+ conn.close()
+
+
+def test_derived_voice_successful_empty_receipt_is_digest_bound(seeded_db) -> None:
+ """A current zero-assertion result is complete without a fabricated class."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "insert into post_voice_classification_analysis "
+ "(post_id, source_body_sha256, orchestrator_model_receipt, assertion_count) "
+ "select post_id, encode(sha256(convert_to(coalesce(post_body, ''), "
+ "'UTF8')), 'hex'), 'chatcmpl-synthetic-empty', 0 from source_post "
+ "where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "select assertion_count from post_voice_classification_analysis "
+ "where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ assert cur.fetchone() == (0,)
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def test_voice_source_reconcile_preserves_other_sourced_memberships(seeded_db) -> None:
+ """A body revision supersedes its source label without erasing another source."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_sha256, "
+ "source_revision_digest) values (%s, 'vom', 'source', repeat('a', 64), "
+ "repeat('b', 64))",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "update source_post set post_body = post_body || ' revised' where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "select voice_concept_code from post_voice_classification_assertion "
+ "where post_id = %s and assertion_status_code = 'source' "
+ "and valid_to is null order by voice_concept_code",
+ (seeded_db["public_post_id"],),
+ )
+ assert [row[0] for row in cur.fetchall()] == ["voc", "vom"]
+ finally:
+ conn.close()
+
+
+def test_voice_assertion_rejects_duplicate_open_scope(seeded_db) -> None:
+ """One post, status, and concept cannot have two current assertions."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute("delete from post_voice_classification_assertion")
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_sha256, "
+ "source_revision_digest) values (%s, 'voc', 'source', repeat('a', 64), "
+ "repeat('b', 64))",
+ (seeded_db["public_post_id"],),
+ )
+ with pytest.raises(psycopg2.errors.UniqueViolation):
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_sha256, "
+ "source_revision_digest) values (%s, 'voc', 'source', repeat('c', 64), "
+ "repeat('d', 64))",
+ (seeded_db["public_post_id"],),
+ )
+ finally:
+ conn.close()
+
+
+def test_voice_taxonomy_matching_multi_membership_is_not_a_disagreement(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """Matching source and derived concept sets remain agreement evidence."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute("delete from post_voice_classification_assertion")
+ for status_code in ("source", "derived"):
+ for concept_code in ("voc", "vom"):
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, "
+ "evidence_span_start, evidence_span_end, evidence_sha256, "
+ "source_revision_digest, orchestrator_model_receipt) "
+ "values (%s, %s, %s, %s, %s, repeat(%s, 64), repeat(%s, 64), %s)",
+ (
+ seeded_db["public_post_id"],
+ concept_code,
+ status_code,
+ 0 if status_code == "derived" else None,
+ 1 if status_code == "derived" else None,
+ "a" if concept_code == "voc" else "b",
+ "c" if concept_code == "voc" else "d",
+ "synthetic-receipt" if status_code == "derived" else None,
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ response = client.get(
+ "/api/voice-taxonomy/summary",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200, response.text
+ payload = response.json()
+ assert payload["multi_membership"] == 1
+ assert payload["disagreement"] == 0
+
+
+def test_voice_taxonomy_excludes_assertions_before_their_validity_window(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """A future assertion is unavailable until its recorded validity begins."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute("delete from post_voice_classification_assertion")
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, "
+ "evidence_sha256, source_revision_digest, valid_from) "
+ "values (%s, 'voc', 'source', repeat('a', 64), repeat('b', 64), "
+ "'2999-01-01T00:00:00Z')",
+ (seeded_db["public_post_id"],),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ response = client.get(
+ "/api/voice-taxonomy/summary",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200, response.text
+ payload = response.json()
+ assert payload["source_count"] == 0
+ assert payload["unavailable"] == payload["total_eligible"]
+
+
+def test_voice_taxonomy_summary_rejects_reversed_period(
+ client, demo_analyst_token
+) -> None:
+ response = client.get(
+ "/api/voice-taxonomy/summary?date_from=2026-02-01&date_to=2026-01-01",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 422
+ assert "Choose an end time" in response.json()["detail"]
+
+
+def test_voice_taxonomy_summary_accepts_one_calendar_day(
+ client, demo_analyst_token
+) -> None:
+ response = client.get(
+ "/api/voice-taxonomy/summary?date_from=2026-01-01&date_to=2026-01-01",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200
+
+
def test_post_detail_exposes_explicit_and_semantic_project_evidence(
client, demo_analyst_token, seeded_db
) -> None:
@@ -1978,15 +2621,26 @@ def test_post_detail_exposes_explicit_and_semantic_project_evidence(
assert listed_post["project_evidence"][0]["provenance"] == "post_project_mention.evidence_text"
-def test_post_detail_as_of_returns_the_cutoff_known_body(
+def test_post_detail_as_of_streams_the_exact_cutoff_body_separately(
client, demo_analyst_token, seeded_db
) -> None:
"""Opened marked titles compare two real sentences, not two clocks."""
headers = {"Authorization": f"Bearer {demo_analyst_token}"}
live = client.get(f"/api/posts/{seeded_db['edited_own_post_id']}", headers=headers)
assert live.status_code == 200
- assert live.json()["post_body"] == "A January post rewritten after the run cutoff."
+ assert "post_body" not in live.json()
assert "known_at" not in live.json()
+ rejected_inline_body = client.get(
+ f"/api/posts/{seeded_db['edited_own_post_id']}",
+ params={"include_body": "true"},
+ headers=headers,
+ )
+ assert rejected_inline_body.status_code == 422
+ live_body = client.get(
+ f"/api/posts/{seeded_db['edited_own_post_id']}/body", headers=headers
+ )
+ assert live_body.status_code == 200
+ assert live_body.text == "A January post rewritten after the run cutoff."
known = client.get(
f"/api/posts/{seeded_db['edited_own_post_id']}",
@@ -1995,10 +2649,19 @@ def test_post_detail_as_of_returns_the_cutoff_known_body(
)
assert known.status_code == 200
body = known.json()
- assert body["post_body"] == "A January post rewritten after the run cutoff."
- assert body["known_at"]["post_body"] == "A January post before the rewrite."
+ assert "post_body" not in body
+ assert "post_body" not in body["known_at"]
assert body["known_at"]["written_at"].startswith("2026-01-10")
+ assert body["product_evidence"] == []
+ assert body["product_evidence_status"]["status_code"] == "historical_unavailable"
assert "postgresql://" not in str(body)
+ known_body = client.get(
+ f"/api/posts/{seeded_db['edited_own_post_id']}/body",
+ params={"as_of": "2026-01-12T12:00:00Z"},
+ headers=headers,
+ )
+ assert known_body.status_code == 200
+ assert known_body.text == "A January post before the rewrite."
missing = client.get(
f"/api/posts/{seeded_db['edited_own_post_id']}",
@@ -2007,6 +2670,12 @@ def test_post_detail_as_of_returns_the_cutoff_known_body(
)
assert missing.status_code == 200
assert "known_at" not in missing.json()
+ missing_body = client.get(
+ f"/api/posts/{seeded_db['edited_own_post_id']}/body",
+ params={"as_of": "2026-01-01T00:00:00Z"},
+ headers=headers,
+ )
+ assert missing_body.status_code == 404
invalid = client.get(
f"/api/posts/{seeded_db['edited_own_post_id']}",
@@ -2497,14 +3166,25 @@ def test_own_corp_private_post_detail_is_readable(client, demo_analyst_token, se
f"/api/posts/{seeded_db['own_private_post_id']}", headers={"Authorization": f"Bearer {demo_analyst_token}"}
)
assert response.status_code == 200
- assert "Test Corp" in response.json()["post_body"]
+ assert "post_body" not in response.json()
+ body = client.get(
+ f"/api/posts/{seeded_db['own_private_post_id']}/body",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert body.status_code == 200
+ assert "Test Corp" in body.text
def test_other_corp_private_post_detail_is_forbidden(client, demo_analyst_token, seeded_db) -> None:
+ headers = {"Authorization": f"Bearer {demo_analyst_token}"}
response = client.get(
- f"/api/posts/{seeded_db['other_private_post_id']}", headers={"Authorization": f"Bearer {demo_analyst_token}"}
+ f"/api/posts/{seeded_db['other_private_post_id']}", headers=headers
)
assert response.status_code == 403
+ body = client.get(
+ f"/api/posts/{seeded_db['other_private_post_id']}/body", headers=headers
+ )
+ assert body.status_code == 403
def test_nonexistent_post_is_not_found(client, demo_analyst_token) -> None:
@@ -4015,6 +4695,7 @@ def answer(self, question: str, sources) -> object:
)
assert submitted.status_code == 202
job_id = submitted.json()["ask_job_id"]
+ _run_global_ask_once(client, job_id)
deadline = _time.monotonic() + 30
body: dict = {}
@@ -5190,6 +5871,7 @@ def answer(self, question, sources): # noqa: ARG002 - contract shape
assert submitted.status_code == 202
job_id = submitted.json()["ask_job_id"]
assert submitted.json()["job_status_code"] == "queued"
+ _run_global_ask_once(client, job_id)
deadline = _time.monotonic() + 30
body: dict = {}
@@ -5254,6 +5936,47 @@ def verify(self, claim):
""",
(seeded_db["public_post_id"],),
)
+ cur.execute(
+ "insert into provenance_resource (resource_iri, resource_label) "
+ "values ('urn:lineageweave:test:public-claim', 'Synthetic public claim') "
+ "returning resource_id"
+ )
+ claim_resource_id = cur.fetchone()[0]
+ cur.execute(
+ "insert into provenance_resource_type (resource_id, class_code) "
+ "values (%s, 'prov_entity')",
+ (claim_resource_id,),
+ )
+ cur.execute(
+ "insert into provenance_resource (resource_iri, resource_label) "
+ "values ('urn:lineageweave:test:public-post-evidence', 'Synthetic source post') "
+ "returning resource_id"
+ )
+ post_resource_id = cur.fetchone()[0]
+ cur.execute(
+ "insert into provenance_resource_type (resource_id, class_code) "
+ "values (%s, 'prov_entity')",
+ (post_resource_id,),
+ )
+ cur.execute(
+ "insert into provenance_resource_binding (resource_id, node_type_code, node_id) "
+ "values (%s, 'node_post', %s)",
+ (post_resource_id, seeded_db["public_post_id"]),
+ )
+ cur.execute(
+ "insert into provenance_assertion "
+ "(subject_resource_id, relation_code, object_resource_id) "
+ "values (%s, 'prov_was_derived_from', %s) returning assertion_id",
+ (claim_resource_id, post_resource_id),
+ )
+ assertion_id = cur.fetchone()[0]
+ cur.execute(
+ "insert into public_claim_envelope "
+ "(source_post_id, provenance_assertion_id, claim_kind_code, claim_text, egress_eligible) "
+ "values (%s, %s, 'claim_public_event', "
+ "'Synthetic Apollo event was published.', true)",
+ (seeded_db["public_post_id"], assertion_id),
+ )
conn.commit()
monkeypatch.setattr("backend.app.main._post_chat_client", lambda **_kwargs: _FakeChatClient())
@@ -5269,6 +5992,7 @@ def verify(self, claim):
)
assert submitted.status_code == 202
job_id = submitted.json()["ask_job_id"]
+ _run_global_ask_once(client, job_id)
deadline = _time.monotonic() + 30
body: dict = {}
@@ -5919,9 +6643,11 @@ def test_seed_period_report_includes_fixture_event_lineage_posts(
def test_seed_period_report_member_click_lands_on_decorated_fixture(
client, demo_analyst_token, seeded_db
) -> None:
- """The first W02 report member must already have Event Lineage,
- Keyman, and evaluation -- otherwise the buyer click opens a dummy
- high/low band row.
+ """The first W02 report member has buyer evidence without fake lineage.
+
+ Event Lineage remains absent until accepted owner weights exist; that
+ missing calibrated channel must not prevent the synthetic post, Keyman,
+ evaluation, and report surfaces from being seeded.
"""
from lineageweave.fixtures import fixture_thread_cast, fixture_titles_in_iso_week
from scripts.seed_demo_data import (
@@ -5980,11 +6706,6 @@ def test_seed_period_report_member_click_lands_on_decorated_fixture(
a100 = next(report for report in threads.json()["reports"] if report["grouping_key"] == "A-100")
post_id = a100["members"][0]["post_id"]
- lineage = client.get(f"/api/posts/{post_id}/lineage", headers=headers)
- assert lineage.status_code == 200, lineage.text
- body = lineage.json()
- assert body["direct"] or body["indirect"]
-
keymen = client.get(f"/api/posts/{post_id}/keymen", headers=headers)
assert keymen.status_code == 200, keymen.text
names = {person["person_name"] for person in keymen.json()["keymen"]}
diff --git a/backend/tests/test_auth_account_resolution.py b/backend/tests/test_auth_account_resolution.py
new file mode 100644
index 000000000..eea21138b
--- /dev/null
+++ b/backend/tests/test_auth_account_resolution.py
@@ -0,0 +1,82 @@
+"""Account-scope resolution query tests."""
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+
+from backend.app.auth import resolve_current_account
+
+
+class _Connection:
+ """Capture the single account projection query."""
+
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, tuple[object, ...]]] = []
+
+ async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
+ """Return one fully projected account row."""
+ self.calls.append((query, args))
+ return {
+ "user_account_id": "account-1",
+ "display_name": "Synthetic Analyst",
+ "preferred_locale": "ko",
+ "corporate_entity_ids": ["entity-1"],
+ "process_unit_ids": ["unit-1"],
+ "permission_codes": ["post_read"],
+ }
+
+
+class _Acquire:
+ """Provide an async pool-acquire context."""
+
+ def __init__(self, connection: _Connection) -> None:
+ self.connection = connection
+
+ async def __aenter__(self) -> _Connection:
+ """Return the captured connection."""
+ return self.connection
+
+ async def __aexit__(self, *_args: object) -> None:
+ """Leave the synthetic connection open."""
+
+
+class _Pool:
+ """Minimal pool used by account resolution."""
+
+ def __init__(self, connection: _Connection) -> None:
+ self.connection = connection
+
+ def acquire(self) -> _Acquire:
+ """Return one acquisition context."""
+ return _Acquire(self.connection)
+
+
+@pytest.mark.parametrize("keyverse_required", [False, True])
+def test_account_scope_uses_one_database_round_trip(keyverse_required: bool) -> None:
+ """Account identity, scope, and permissions resolve in one query."""
+ connection = _Connection()
+ claims = {"sub": "subject-1"}
+ if keyverse_required:
+ claims.update({"org": "ORG", "workspace": "PU", "role": ["member"]})
+
+ account = asyncio.run(
+ resolve_current_account(
+ _Pool(connection), # type: ignore[arg-type]
+ claims,
+ SimpleNamespace(keyverse_claim_binding_required=keyverse_required), # type: ignore[arg-type]
+ )
+ )
+
+ assert len(connection.calls) == 1
+ assert account.corporate_entity_ids == frozenset({"entity-1"})
+ assert account.process_unit_ids == frozenset({"unit-1"})
+ assert account.permission_codes == frozenset({"post_read"})
+ query, arguments = connection.calls[0]
+ assert "group by account.user_account_id" not in query.lower()
+ if keyverse_required:
+ assert "role.role_code = any($4::text[])" in query
+ assert arguments == ("subject-1", "ORG", "PU", ["member"])
+ else:
+ assert "where affiliation.user_account_id = account.user_account_id" in query
+ assert arguments == ("subject-1",)
diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py
new file mode 100644
index 000000000..6ad292a23
--- /dev/null
+++ b/backend/tests/test_db.py
@@ -0,0 +1,71 @@
+"""Database pool configuration tests."""
+
+import asyncio
+
+from backend.app import db
+
+
+def test_pool_disables_measured_short_query_jit(monkeypatch) -> None:
+ """Connections avoid PostgreSQL JIT startup on latency-bounded reads."""
+ captured: dict[str, object] = {}
+
+ async def fake_create_pool(database_url: str, **kwargs: object) -> object:
+ captured.update(database_url=database_url, **kwargs)
+ return object()
+
+ monkeypatch.setattr(db.asyncpg, "create_pool", fake_create_pool)
+
+ asyncio.run(db.create_pool("postgresql://synthetic"))
+
+ assert captured == {
+ "database_url": "postgresql://synthetic",
+ "min_size": 10,
+ "max_size": 10,
+ "max_cacheable_statement_size": 0,
+ "server_settings": {"jit": "off", "plan_cache_mode": "force_generic_plan"},
+ "init": db._initialize_connection,
+ "reset": db._reset_connection,
+ }
+
+
+def test_pool_initialization_loads_auth_array_codecs(monkeypatch) -> None:
+ """Every new connection loads UUID/text arrays before serving reads."""
+ calls: list[str] = []
+
+ class Connection:
+ """Capture the initialization statement."""
+
+ async def fetchrow(self, query: str, *args: object) -> None:
+ """Record the query without a database."""
+ calls.append(query)
+
+ async def warm(connection: object) -> None:
+ assert isinstance(connection, Connection)
+ calls.append("voice-read-statements-warmed")
+
+ async def warm_dashboard(connection: object) -> None:
+ assert isinstance(connection, Connection)
+ calls.append("dashboard-read-statements-warmed")
+
+ async def warm_customer(connection: object) -> None:
+ assert isinstance(connection, Connection)
+ calls.append("customer-read-paths-warmed")
+
+ async def warm_posts(connection: object) -> None:
+ assert isinstance(connection, Connection)
+ calls.append("post-read-paths-warmed")
+
+ monkeypatch.setattr(db, "warm_voice_taxonomy_read_statements", warm)
+ monkeypatch.setattr(db, "warm_operations_dashboard_read_statements", warm_dashboard)
+ monkeypatch.setattr(db, "warm_customer_master_read_paths", warm_customer)
+ monkeypatch.setattr(db, "warm_post_list_read_paths", warm_posts)
+
+ asyncio.run(db._initialize_connection(Connection())) # type: ignore[arg-type]
+
+ assert calls == [
+ "select array[]::uuid[] as uuid_values, array[]::text[] as text_values",
+ "dashboard-read-statements-warmed",
+ "voice-read-statements-warmed",
+ "customer-read-paths-warmed",
+ "post-read-paths-warmed",
+ ]
diff --git a/backend/tests/test_operations_dashboard_postgres.py b/backend/tests/test_operations_dashboard_postgres.py
new file mode 100644
index 000000000..9920e88b5
--- /dev/null
+++ b/backend/tests/test_operations_dashboard_postgres.py
@@ -0,0 +1,61 @@
+"""Real-PostgreSQL contract test for the operations Dashboard projection."""
+
+from __future__ import annotations
+
+import os
+
+import asyncpg
+import pytest
+
+from backend.app.operations_dashboard import fetch_operations_dashboard
+
+
+_POSTGRES_DSN = os.environ.get(
+ "LINEAGEWEAVE_TEST_POSTGRES_DSN",
+ "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave",
+)
+
+
+@pytest.mark.anyio
+async def test_operations_dashboard_sql_binds_against_postgres() -> None:
+ """Execute every Dashboard query through asyncpg's real parser and binder."""
+ try:
+ connection = await asyncpg.connect(_POSTGRES_DSN, timeout=2)
+ except (OSError, asyncpg.PostgresError):
+ pytest.skip("requires the migrated local Compose database")
+ try:
+ required_tables = (
+ "operations_case_classification",
+ "operations_case_missing_fact",
+ "operations_case_milestone",
+ "operations_case_missing_milestone",
+ "topic_context_membership",
+ "topic_activity_interval",
+ "topic_post_context_influence",
+ "dashboard_case_contributor_read_projection",
+ )
+ for table_name in required_tables:
+ if await connection.fetchval(
+ "select to_regclass($1)", f"public.{table_name}"
+ ) is None:
+ pytest.skip(f"requires the migration that creates {table_name}")
+ if not await connection.fetchval(
+ "select exists (select 1 from information_schema.columns "
+ "where table_schema = 'public' and table_name = 'post_product_analysis' "
+ "and column_name = 'orchestrator_model_receipt')"
+ ):
+ pytest.skip("requires the receipt-bearing product analysis migration")
+ result = await fetch_operations_dashboard(connection, [])
+ external_result = await fetch_operations_dashboard(connection, [], external_only=True)
+ finally:
+ await connection.close()
+
+ assert result["total_post_count"] >= 0
+ assert external_result["total_post_count"] == result["total_post_count"]
+ assert result["topic_context"]["status_code"] in {"accepted", "unavailable"}
+
+
+@pytest.fixture
+def anyio_backend() -> str:
+ """Use the installed asyncio backend for the asyncpg contract test."""
+ return "asyncio"
diff --git a/backend/tests/test_product_semantic_ingestion.py b/backend/tests/test_product_semantic_ingestion.py
new file mode 100644
index 000000000..e33c6f827
--- /dev/null
+++ b/backend/tests/test_product_semantic_ingestion.py
@@ -0,0 +1,242 @@
+"""Tests for normalized product semantic persistence."""
+
+from contextlib import asynccontextmanager
+import asyncio
+
+import pytest
+
+from backend.app.product_semantic_ingestion import (
+ load_current_product_relation_targets,
+ persist_product_mentions,
+ resolve_product_mentions,
+)
+from lineageweave.product_semantics import (
+ ProductEvidenceSource,
+ ProductExtraction,
+ ProductExtractionResult,
+ ProductMention,
+ ProductRelation,
+ ProductRelationTarget,
+ ResolvedProductMention,
+ product_analysis_input_sha256,
+)
+
+
+class _Connection:
+ def __init__(
+ self,
+ rows: list[dict[str, str]] | None = None,
+ *,
+ source_body: str = "Synthetic source body",
+ source_digest: str | None = None,
+ operation_rows: list[dict[str, object]] | None = None,
+ project_rows: list[dict[str, object]] | None = None,
+ ) -> None:
+ self.rows = rows or []
+ self.source_body = source_body
+ self.source_digest = source_digest or ProductEvidenceSource(
+ "post-a", source_body
+ ).input_sha256
+ self.operation_rows = operation_rows or []
+ self.project_rows = project_rows or []
+ self.calls: list[tuple[str, tuple[object, ...]]] = []
+
+ @asynccontextmanager
+ async def transaction(self):
+ yield
+
+ async def fetch(self, query: str, *args: object) -> list[dict[str, str]]:
+ self.calls.append((query, args))
+ if "from operations_case_fact" in query:
+ return self.operation_rows # type: ignore[return-value]
+ if "from post_project_mention" in query:
+ return self.project_rows # type: ignore[return-value]
+ return self.rows
+
+ async def fetchval(self, query: str, *args: object) -> str:
+ self.calls.append((query, args))
+ return self.source_digest
+
+ async def fetchrow(self, query: str, *args: object) -> dict[str, str]:
+ self.calls.append((query, args))
+ return {
+ "post_body": self.source_body,
+ "source_body_sha256": self.source_digest,
+ }
+
+ async def execute(self, query: str, *args: object) -> None:
+ self.calls.append((query, args))
+
+
+def test_resolve_product_mentions_uses_parameterized_normalized_alias() -> None:
+ connection = _Connection([{"product_catalog_id": "catalog-a"}])
+ mention = ProductMention(" PRODUCT Q ", "PRODUCT", "post-a", "a" * 64)
+ resolved = asyncio.run(resolve_product_mentions(connection, (mention,)))
+ assert resolved[0].product_catalog_id == "catalog-a"
+ assert connection.calls[0][1] == ("product q",)
+
+
+def test_resolve_product_mentions_preserves_catalog_tie() -> None:
+ connection = _Connection(
+ [{"product_catalog_id": "catalog-a"}, {"product_catalog_id": "catalog-b"}]
+ )
+ mention = ProductMention("Product Q", "Product Q", "post-a", "a" * 64)
+ resolved = asyncio.run(resolve_product_mentions(connection, (mention,)))
+ assert resolved[0].resolution_status_code == "tie"
+ assert resolved[0].product_catalog_id is None
+
+
+def test_persist_product_mentions_replaces_exact_projection() -> None:
+ connection = _Connection()
+ source = ProductEvidenceSource("post-a", connection.source_body)
+ input_digest = product_analysis_input_sha256((source,), ())
+ mention = ProductMention("Product Q", "Product Q", "post-a", "a" * 64)
+ resolved = ResolvedProductMention(mention, "missing", None)
+ asyncio.run(
+ persist_product_mentions(
+ connection,
+ "post-a",
+ input_digest,
+ "session-a",
+ (resolved,),
+ ProductExtractionResult(
+ source.input_sha256,
+ "receipt-a",
+ ProductExtraction((mention,), ()),
+ ),
+ expected_operations_input_sha256=None,
+ )
+ )
+ assert len(connection.calls) == 6
+ assert "for update" in connection.calls[0][0]
+ assert "from operations_case_fact" in connection.calls[1][0]
+ assert "from post_project_mention" in connection.calls[2][0]
+ assert connection.calls[3][1] == ("post-a",)
+ assert connection.calls[5][1] == (
+ "post-a",
+ 0,
+ None,
+ "Product Q",
+ "missing",
+ "Product Q",
+ "post-a",
+ "a" * 64,
+ )
+
+
+def test_persist_product_mentions_writes_authorized_relation_in_same_transaction() -> None:
+ source_body = "Synthetic source body with Product Q"
+ project_row = {"project_key": "project-a", "project_name": "Project A"}
+ connection = _Connection(source_body=source_body, project_rows=[project_row])
+ source = ProductEvidenceSource("post-a", source_body)
+ target = ProductRelationTarget(
+ "project:project-a", "project", "Project A", ("post-a", "project-a")
+ )
+ input_digest = product_analysis_input_sha256((source,), (target,))
+ mention = ProductMention("Product Q", "Product Q", "post-a", "a" * 64)
+ relation = ProductRelation(
+ 0,
+ "project:project-a",
+ "project",
+ "used_by_project",
+ "Product Q",
+ "post-a",
+ "a" * 64,
+ ("post-a", "project-a"),
+ )
+ asyncio.run(
+ persist_product_mentions(
+ connection,
+ "post-a",
+ input_digest,
+ "session-a",
+ (ResolvedProductMention(mention, "missing", None),),
+ ProductExtractionResult(
+ source.input_sha256,
+ "receipt-a",
+ ProductExtraction((mention,), (relation,)),
+ ),
+ expected_operations_input_sha256=None,
+ )
+ )
+ assert "insert into product_project_relation" in connection.calls[-1][0]
+ assert connection.calls[-1][1][0:4] == ("post-a", 0, "project-a", "used_by_project")
+
+
+def test_persist_product_mentions_rejects_stale_source_revision() -> None:
+ """A provider result cannot replace products after the focal body changes."""
+ connection = _Connection()
+ mention = ProductMention("Product Q", "Product Q", "post-a", "a" * 64)
+ result = ProductExtractionResult(
+ "c" * 64,
+ "receipt-a",
+ ProductExtraction((mention,), ()),
+ )
+ with pytest.raises(ValueError, match="source revision"):
+ asyncio.run(
+ persist_product_mentions(
+ connection,
+ "post-a",
+ "d" * 64,
+ "session-a",
+ (ResolvedProductMention(mention, "missing", None),),
+ result,
+ expected_operations_input_sha256=None,
+ )
+ )
+ assert len(connection.calls) == 1
+
+
+def test_persist_product_mentions_rejects_changed_target_window() -> None:
+ """A target added during extraction prevents stale relation publication."""
+ source_body = "Synthetic source body with project evidence"
+ source = ProductEvidenceSource("post-a", source_body)
+ connection = _Connection(
+ source_body=source_body,
+ project_rows=[{"project_key": "project-a", "project_name": "Project A"}],
+ )
+ stale_input_digest = product_analysis_input_sha256((source,), ())
+ result = ProductExtractionResult(
+ source.input_sha256,
+ "receipt-a",
+ ProductExtraction((), ()),
+ )
+
+ with pytest.raises(ValueError, match="relation targets"):
+ asyncio.run(
+ persist_product_mentions(
+ connection,
+ "post-a",
+ stale_input_digest,
+ "session-a",
+ (),
+ result,
+ expected_operations_input_sha256=None,
+ )
+ )
+
+ assert all("delete from post_product_analysis" not in query for query, _ in connection.calls)
+
+
+def test_load_current_product_relation_targets_binds_exact_analysis() -> None:
+ """Only typed targets from the exact evidence digests enter the prompt."""
+ connection = _Connection(
+ operation_rows=[{
+ "case_kind_code": "claim_investigation",
+ "fact_ordinal": 0,
+ "fact_type_code": "order",
+ "value_text": "Synthetic order",
+ }],
+ project_rows=[{"project_key": "project-a", "project_name": "Project A"}],
+ )
+ targets = asyncio.run(
+ load_current_product_relation_targets(
+ connection, "post-a", "a" * 64, "b" * 64
+ )
+ )
+ assert [target.target_kind_code for target in targets] == [
+ "operations_fact",
+ "project",
+ ]
+ assert connection.calls[0][1] == ("post-a", "a" * 64, "b" * 64)
+ assert connection.calls[1][1] == ("post-a", "a" * 64)
diff --git a/backend/tests/test_voice_taxonomy.py b/backend/tests/test_voice_taxonomy.py
new file mode 100644
index 000000000..cc67f027d
--- /dev/null
+++ b/backend/tests/test_voice_taxonomy.py
@@ -0,0 +1,195 @@
+"""Tests for authorized voice-taxonomy aggregate queries."""
+
+import asyncio
+
+from backend.app import main
+from backend.app.auth import CurrentAccount
+from backend.app.voice_taxonomy import (
+ load_voice_taxonomy_summary,
+ warm_voice_taxonomy_read_statements,
+)
+
+
+class _Connection:
+ def __init__(self) -> None:
+ self.args: tuple[object, ...] = ()
+
+ async def fetchrow(self, query: str, *args: object):
+ assert "post_product_mention" in query
+ assert "post_project_mention" in query
+ assert "post.visibility_code = 'public'" in query
+ assert "cardinality($2::uuid[]) = 0" in query
+ assert "not (post.corporate_entity_id = any($11::uuid[]))" in query
+ assert "source_deleted_flag" in query
+ self.args = args
+ return {
+ "total_eligible": 4,
+ "classified_unique": 1,
+ "multi_membership": 1,
+ "source_count": 2,
+ "derived_count": 1,
+ "unavailable": 2,
+ "disagreement": 1,
+ "category_post_counts": {"voc": 2, "vom": 1},
+ }
+
+
+def test_voice_summary_binds_authorization_and_every_filter() -> None:
+ connection = _Connection()
+ summary = asyncio.run(
+ load_voice_taxonomy_summary(
+ connection,
+ authorized_corporate_entity_ids=("corp-a",),
+ authorized_process_unit_ids=("pu-a",),
+ date_from="from",
+ date_to="to",
+ corporate_entity_id="corp-filter",
+ process_unit_id="pu-filter",
+ team_id="team-filter",
+ person_id="person-filter",
+ product_catalog_id="product-filter",
+ project_key="project-filter",
+ excluded_corporate_entity_ids=("demo-corp",),
+ )
+ )
+ assert summary["total_eligible"] == 4
+ assert connection.args == (
+ ["corp-a"], ["pu-a"], "from", "to", "corp-filter", "pu-filter",
+ "team-filter", "person-filter", "product-filter", "project-filter",
+ ["demo-corp"],
+ )
+
+
+def test_voice_summary_uses_exact_month_projection() -> None:
+ """The common authorized read sums the maintained monthly partitions."""
+
+ class ProjectionConnection:
+ async def fetchrow(self, query: str, *args: object):
+ assert "voice_taxonomy_month_read_projection" in query
+ assert "source_post post" not in query
+ assert args == (
+ ["corp-a"], [], None, None, None, None, ["demo-corp"], True,
+ )
+ return {
+ "total_eligible": 7,
+ "classified_unique": 5,
+ "multi_membership": 1,
+ "source_count": 6,
+ "derived_count": 2,
+ "unavailable": 1,
+ "disagreement": 1,
+ "category_post_counts": {"voc": 4, "vop": 2},
+ "projection_stale": False,
+ }
+
+ summary = asyncio.run(
+ load_voice_taxonomy_summary(
+ ProjectionConnection(),
+ authorized_corporate_entity_ids=("corp-a",),
+ authorized_process_unit_ids=(),
+ excluded_corporate_entity_ids=("demo-corp",),
+ source_context_required=True,
+ )
+ )
+ assert summary == {
+ "total_eligible": 7,
+ "classified_unique": 5,
+ "multi_membership": 1,
+ "source_count": 6,
+ "derived_count": 2,
+ "unavailable": 1,
+ "disagreement": 1,
+ "category_post_counts": {"voc": 4, "vop": 2},
+ }
+
+
+def test_voice_summary_prepares_all_read_shapes() -> None:
+ """Pool startup executes each statement shape before accepting reads."""
+
+ class Connection:
+ def __init__(self) -> None:
+ self.queries: list[str] = []
+
+ async def fetchrow(self, query: str, *_args: object):
+ self.queries.append(query)
+ return {
+ "total_eligible": 0, "classified_unique": 0,
+ "multi_membership": 0, "source_count": 0,
+ "derived_count": 0, "unavailable": 0, "disagreement": 0,
+ "category_post_counts": {}, "projection_stale": False,
+ }
+
+ connection = Connection()
+ asyncio.run(warm_voice_taxonomy_read_statements(connection))
+ assert "voice_taxonomy_month_read_projection" in connection.queries[0]
+ assert "voice_taxonomy_day_read_projection" in connection.queries[1]
+ assert "voice_taxonomy_post_read_projection" in connection.queries[2]
+
+
+def test_voice_summary_excludes_demo_entities_when_real_context_exists(monkeypatch) -> None:
+ """A real-data account never mixes synthetic seed rows into its denominator."""
+ captured: dict[str, object] = {}
+
+ class Acquire:
+ async def __aenter__(self):
+ return object()
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ class Pool:
+ def acquire(self) -> Acquire:
+ return Acquire()
+
+ async def has_real(_conn: object, entity_ids: list[str]) -> bool:
+ assert entity_ids == ["00000000-0000-0000-0000-000000000001"]
+ return True
+
+ async def demo_ids(_conn: object) -> set[str]:
+ return {"00000000-0000-0000-0000-000000000099"}
+
+ async def load(_conn: object, **kwargs: object) -> dict[str, object]:
+ captured.update(kwargs)
+ return {
+ "total_eligible": 0,
+ "classified_unique": 0,
+ "multi_membership": 0,
+ "source_count": 0,
+ "derived_count": 0,
+ "unavailable": 0,
+ "disagreement": 0,
+ "category_post_counts": {},
+ }
+
+ monkeypatch.setattr(main, "has_real_source_context", has_real)
+ monkeypatch.setattr(main, "fetch_demo_corporate_entity_ids", demo_ids)
+ monkeypatch.setattr(main, "load_voice_taxonomy_summary", load)
+ account = CurrentAccount(
+ user_account_id="00000000-0000-0000-0000-000000000010",
+ external_subject_id="synthetic-subject",
+ display_name="Synthetic reader",
+ preferred_locale="en",
+ corporate_entity_ids=frozenset({"00000000-0000-0000-0000-000000000001"}),
+ process_unit_ids=frozenset(),
+ permission_codes=frozenset({"post_read"}),
+ )
+
+ result = asyncio.run(
+ main.read_voice_taxonomy_summary(
+ date_from=None,
+ date_to=None,
+ corporate_entity_id=None,
+ process_unit_id=None,
+ team_id=None,
+ person_id=None,
+ product_catalog_id=None,
+ project_key=None,
+ account=account,
+ pool=Pool(),
+ )
+ )
+
+ assert result["total_eligible"] == 0
+ assert captured["excluded_corporate_entity_ids"] == (
+ "00000000-0000-0000-0000-000000000099",
+ )
diff --git a/backend/tests/test_voice_taxonomy_transition_worker.py b/backend/tests/test_voice_taxonomy_transition_worker.py
new file mode 100644
index 000000000..82f146a52
--- /dev/null
+++ b/backend/tests/test_voice_taxonomy_transition_worker.py
@@ -0,0 +1,56 @@
+"""Tests for transition-instant Voice projection reconciliation."""
+
+import asyncio
+
+from backend.app import voice_taxonomy_transition_worker as worker
+
+
+def test_transition_worker_waits_for_database_notification(monkeypatch) -> None:
+ """No configured polling interval is used when no transition is pending."""
+
+ class Connection:
+ def __init__(self) -> None:
+ self.listener = None
+ self.delay_queries = 0
+ self.closed = False
+
+ async def add_listener(self, channel, listener) -> None:
+ assert channel == "voice_taxonomy_transition"
+ self.listener = listener
+
+ async def fetchval(self, query: str):
+ if "reconcile_due" in query:
+ return 0
+ self.delay_queries += 1
+ return None
+
+ async def remove_listener(self, channel, listener) -> None:
+ assert channel == "voice_taxonomy_transition"
+ assert listener is self.listener
+
+ async def close(self) -> None:
+ self.closed = True
+
+ connection = Connection()
+
+ async def connect(database_url: str, **kwargs):
+ assert database_url == "postgresql://synthetic"
+ assert kwargs == {"server_settings": {"jit": "off"}}
+ return connection
+
+ monkeypatch.setattr(worker.asyncpg, "connect", connect)
+
+ async def exercise() -> None:
+ task = asyncio.create_task(
+ worker.run_voice_taxonomy_transition_worker("postgresql://synthetic")
+ )
+ while connection.listener is None or connection.delay_queries == 0:
+ await asyncio.sleep(0)
+ connection.listener(connection, 1, "voice_taxonomy_transition", "")
+ while connection.delay_queries < 2:
+ await asyncio.sleep(0)
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+
+ asyncio.run(exercise())
+ assert connection.closed
diff --git a/backend/worker-healthcheck.sh b/backend/worker-healthcheck.sh
new file mode 100755
index 000000000..d221cabfc
--- /dev/null
+++ b/backend/worker-healthcheck.sh
@@ -0,0 +1,74 @@
+#!/bin/sh
+# Check that the durable worker heartbeat advanced without importing Python.
+#
+# The worker writes a trusted monotonic integer. This probe keeps the same
+# progress contract as backend.app.worker_health while avoiding a Python
+# interpreter and package import for every container health check.
+
+set -eu
+
+heartbeat_path=${1:-/tmp/lineageweave-worker-heartbeat}
+state_path=${2:-/tmp/lineageweave-worker-healthcheck-state}
+
+decimal_le_same_width() {
+ left=$1
+ right=$2
+ while [ -n "$left" ]; do
+ left_digit=${left%"${left#?}"}
+ right_digit=${right%"${right#?}"}
+ if [ "$left_digit" -lt "$right_digit" ]; then
+ return 0
+ fi
+ if [ "$left_digit" -gt "$right_digit" ]; then
+ return 1
+ fi
+ left=${left#?}
+ right=${right#?}
+ done
+ return 0
+}
+
+valid_counter() {
+ value=$1
+ case "$value" in ''|*[!0-9]*) return 1 ;; esac
+ [ "${#value}" -le 19 ] || return 1
+ if [ "${#value}" -eq 19 ]; then
+ decimal_le_same_width "$value" 9223372036854775807 || return 1
+ fi
+}
+
+current_sample=$(cat "$heartbeat_path" 2>/dev/null) || exit 1
+set -- $current_sample
+[ "$#" -eq 3 ] || exit 1
+current_version=$1
+current_epoch=$2
+current_heartbeat=$3
+[ "$current_version" = v1 ] || exit 1
+[ "${#current_epoch}" -eq 32 ] || exit 1
+case "$current_epoch" in ''|*[!0-9a-f]*) exit 1 ;; esac
+valid_counter "$current_heartbeat" || exit 1
+
+if previous_sample=$(cat "$state_path" 2>/dev/null); then
+ set -- $previous_sample
+ if [ "$#" -eq 3 ]; then
+ previous_version=$1
+ previous_epoch=$2
+ previous_heartbeat=$3
+ [ "$previous_version" = v1 ] || exit 1
+ [ "${#previous_epoch}" -eq 32 ] || exit 1
+ case "$previous_epoch" in ''|*[!0-9a-f]*) exit 1 ;; esac
+ valid_counter "$previous_heartbeat" || exit 1
+ if [ "$previous_epoch" = "$current_epoch" ] \
+ && [ "$current_heartbeat" -le "$previous_heartbeat" ]; then
+ exit 1
+ fi
+ else
+ exit 1
+ fi
+fi
+
+temporary_state="${state_path}.$$"
+trap 'rm -f "$temporary_state"' EXIT HUP INT TERM
+printf '%s\n' "$current_sample" > "$temporary_state"
+mv "$temporary_state" "$state_path"
+trap - EXIT HUP INT TERM
diff --git a/docker-compose.postgres-tuned.yml b/docker-compose.postgres-tuned.yml
new file mode 100644
index 000000000..ef3843e1d
--- /dev/null
+++ b/docker-compose.postgres-tuned.yml
@@ -0,0 +1,14 @@
+services:
+ postgres:
+ command:
+ - postgres
+ - -c
+ - max_wal_size=${POSTGRES_TUNED_MAX_WAL_SIZE:?generate and validate a tuning plan first}
+ - -c
+ - wal_buffers=${POSTGRES_TUNED_WAL_BUFFERS:?generate and validate a tuning plan first}
+ - -c
+ - fsync=${POSTGRES_TUNED_FSYNC:?generate and validate a tuning plan first}
+ - -c
+ - full_page_writes=${POSTGRES_TUNED_FULL_PAGE_WRITES:?generate and validate a tuning plan first}
+ - -c
+ - synchronous_commit=${POSTGRES_TUNED_SYNCHRONOUS_COMMIT:?generate and validate a tuning plan first}
diff --git a/docker-compose.yml b/docker-compose.yml
index d0a2422aa..8efb6800f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,3 +1,5 @@
+name: lineageweave
+
services:
postgres:
# Built (not bind-mounted) so the keycloak-db init script and the
@@ -103,25 +105,32 @@ services:
build:
context: ./docker/contextual-orchestrator
dockerfile: Dockerfile
+ args:
+ CONTEXTUAL_ORCHESTRATOR_SOURCE_REVISION: c25712646bb25d0d30e4a5146ca9ea54669dfdf6
+ image: ${COMPOSE_PROJECT_NAME:-lineageweave}-orchestrator:c25712646bb25d0d30e4a5146ca9ea54669dfdf6
env_file:
- ${HOME}/.env
environment:
AGENTS_FILE: /app/agents.json
PORT: 8000
CONTEXTUAL_ORCHESTRATOR_TOKEN: ${CONTEXTUAL_ORCHESTRATOR_TOKEN:-${ORCHESTRATOR_API_KEY:-lineageweave-orchestrator-dev-only}}
- # Gateway credentials and URL are supplied only by env_file (${HOME}/.env).
+ # Gateway credentials, URL, and provider allowlist are supplied only by
+ # env_file (${HOME}/.env).
# Do not repeat them under environment:, where Compose interpolation can
# overwrite env_file values with an empty host-shell value.
# The upstream default remains 64 KiB for ordinary text APIs. Buyer
# image blocks are base64 data URIs, so the multimodal boundary gets an
# 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}
+ BATCH_JOB_REGISTRY_VALKEY_URL: redis://valkey:6379/1
OTEL_SERVICE_NAME: ${OTEL_ORCHESTRATOR_SERVICE_NAME:-contextual-orchestrator}
# Do not set OTEL_EXPORTER_OTLP_ENDPOINT here. An empty
# ${OTEL_EXPORTER_OTLP_ENDPOINT:-} interpolation would wipe a value from
# env_file (${HOME}/.env). Export stays opt-in from that file or the host.
command: ["python", "/app/start.py"]
+ depends_on:
+ valkey:
+ condition: service_healthy
ports:
- "${ORCHESTRATOR_PORT:-18000}:8000"
healthcheck:
@@ -140,7 +149,9 @@ services:
build:
context: .
dockerfile: backend/Dockerfile
- environment:
+ args:
+ LINEAGEWEAVE_SOURCE_REVISION: ${LINEAGEWEAVE_SOURCE_REVISION:-unknown}
+ environment: &backend-environment
DATABASE_URL: postgresql://${POSTGRES_USER:-lineageweave}:${POSTGRES_PASSWORD:-lineageweave_dev_only}@postgres:5432/${POSTGRES_DB:-lineageweave}
# Internal DNS name for JWKS fetches (always reachable from inside the
# compose network); KEYCLOAK_ISSUER is the *external*, host-published
@@ -173,9 +184,15 @@ services:
# LLM_GATEWAY_API_KEY in the orchestrator's private env file.
ORCHESTRATOR_BASE_URL: ${ORCHESTRATOR_BASE_URL:-http://orchestrator:8000}
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-${CONTEXTUAL_ORCHESTRATOR_TOKEN:-lineageweave-orchestrator-dev-only}}
+ ORCHESTRATOR_ROUTING_ENDPOINT: ${ORCHESTRATOR_ROUTING_ENDPOINT:-}
SEARXNG_BASE_URL: http://searxng:8080
TEPP_TRANSPORT_URL: ${TEPP_TRANSPORT_URL:-}
TEPP_API_KEY: ${TEPP_API_KEY:-}
+ TOPIC_INFLUENCE_TRANSPORT_URL: ${TOPIC_INFLUENCE_TRANSPORT_URL:-}
+ TOPIC_INFLUENCE_API_KEY: ${TOPIC_INFLUENCE_API_KEY:-}
+ TOPIC_INFLUENCE_REQUEST_TIMEOUT_SECONDS: ${TOPIC_INFLUENCE_REQUEST_TIMEOUT_SECONDS:-}
+ TOPIC_INFLUENCE_LEASE_TIMEOUT_SECONDS: ${TOPIC_INFLUENCE_LEASE_TIMEOUT_SECONDS:-}
+ TOPIC_INFLUENCE_POLL_SECONDS: ${TOPIC_INFLUENCE_POLL_SECONDS:-}
CALDAV_BASE_URL: ${CALDAV_BASE_URL:-}
NARUON_CALENDAR_BASE_URL: ${NARUON_CALENDAR_BASE_URL:-}
NARUON_CALENDAR_SERVICE_TOKEN: ${NARUON_CALENDAR_SERVICE_TOKEN:-}
@@ -198,12 +215,77 @@ services:
condition: service_healthy
searxng:
condition: service_healthy
+ # The HTTP process deliberately does not consume durable queues. A
+ # targeted `docker compose up backend` starts only the Ask owner; the
+ # independent broad worker must never be required to serve or poll Ask.
+ backend-ask-worker:
+ condition: service_healthy
+
+ backend-worker:
+ build:
+ context: .
+ dockerfile: backend/Dockerfile
+ args:
+ LINEAGEWEAVE_SOURCE_REVISION: ${LINEAGEWEAVE_SOURCE_REVISION:-unknown}
+ command: ["python", "-m", "backend.app.worker"]
+ restart: unless-stopped
+ environment:
+ <<: *backend-environment
+ LINEAGEWEAVE_WORKER_CONSUMERS: analysis_run,post_content,voice_taxonomy,topic_influence
+ depends_on:
+ postgres:
+ condition: service_healthy
+ database_migration:
+ condition: service_completed_successfully
+ orchestrator:
+ condition: service_healthy
+ valkey:
+ condition: service_healthy
+ searxng:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "/bin/sh", "/app/backend/worker-healthcheck.sh"]
+ interval: 10s
+ timeout: 3s
+ retries: 3
+ start_period: 5s
+
+ backend-ask-worker:
+ build:
+ context: .
+ dockerfile: backend/Dockerfile
+ args:
+ LINEAGEWEAVE_SOURCE_REVISION: ${LINEAGEWEAVE_SOURCE_REVISION:-unknown}
+ command: ["python", "-m", "backend.app.worker"]
+ restart: unless-stopped
+ environment:
+ <<: *backend-environment
+ LINEAGEWEAVE_WORKER_CONSUMERS: global_ask
+ depends_on:
+ postgres:
+ condition: service_healthy
+ database_migration:
+ condition: service_completed_successfully
+ orchestrator:
+ condition: service_healthy
+ valkey:
+ condition: service_healthy
+ searxng:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "/bin/sh", "/app/backend/worker-healthcheck.sh"]
+ interval: 10s
+ timeout: 3s
+ retries: 3
+ start_period: 5s
mcp:
profiles: ["mcp"]
build:
context: .
dockerfile: backend/Dockerfile
+ args:
+ LINEAGEWEAVE_SOURCE_REVISION: ${LINEAGEWEAVE_SOURCE_REVISION:-unknown}
command: ["uvicorn", "backend.app.mcp_server:app", "--host", "0.0.0.0", "--port", "8001"]
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-lineageweave}:${POSTGRES_PASSWORD:-lineageweave_dev_only}@postgres:5432/${POSTGRES_DB:-lineageweave}
@@ -224,15 +306,12 @@ services:
VALKEY_URL: redis://valkey:6379/0
ORCHESTRATOR_BASE_URL: ${ORCHESTRATOR_BASE_URL:-http://orchestrator:8000}
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-${CONTEXTUAL_ORCHESTRATOR_TOKEN:-lineageweave-orchestrator-dev-only}}
- # Local Keycloak mints this exact fixed audience. Production Keyverse
- # deployments configure both values together outside this demo stack.
+ ORCHESTRATOR_ROUTING_ENDPOINT: ${ORCHESTRATOR_ROUTING_ENDPOINT:-}
MCP_RESOURCE_URL: http://localhost:18001/mcp
MCP_AUDIENCE: http://localhost:18001/mcp
MCP_ALLOWED_HOSTS: localhost:*,127.0.0.1:*,mcp:8001
MCP_ALLOWED_ORIGINS: ${MCP_ALLOWED_ORIGINS:-}
MCP_MAX_REQUEST_BYTES: ${MCP_MAX_REQUEST_BYTES:-65536}
- # No guessed quota: operators must supply values justified by the k6
- # capacity artifact for their deployment before enabling this profile.
MCP_RATE_LIMIT_REQUESTS: ${MCP_RATE_LIMIT_REQUESTS:-}
MCP_RATE_LIMIT_WINDOW_SECONDS: ${MCP_RATE_LIMIT_WINDOW_SECONDS:-}
ports:
@@ -255,6 +334,7 @@ services:
build:
context: ./frontend
args:
+ LINEAGEWEAVE_SOURCE_REVISION: ${LINEAGEWEAVE_SOURCE_REVISION:-unknown}
VITE_KEYVERSE_ISSUER: ${KEYVERSE_ISSUER:-http://localhost:${KEYCLOAK_PORT:-18080}/realms/lineageweave-demo}
VITE_KEYVERSE_CLIENT_ID: ${KEYVERSE_CLIENT_ID:-lineageweave-frontend}
VITE_BACKEND_BASE_URL: http://localhost:${BACKEND_PORT:-18420}
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 0af60f58c..4f00e9bd9 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -1,24 +1,50 @@
+# Build the exact Rust token-packer shipped by the pinned upstream archive.
+# The native module is mandatory: token budgets, vector reductions, and RMSE
+# fail closed rather than falling back to Python arithmetic.
+ARG MATURIN_BUILDER_IMAGE=ghcr.io/pyo3/maturin@sha256:b6c8b59a0170b77eb31a35b56034abd39972483ad0ebfff344deaa42a85f3bd3
+FROM ${MATURIN_BUILDER_IMAGE} AS token-builder
+
+ADD --checksum=sha256:4c1bbb3f2a7821c19d4eeca45fe772371b3a4b06a8d7c1a623b9670521b9b062 https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/c25712646bb25d0d30e4a5146ca9ea54669dfdf6.tar.gz /tmp/contextual-orchestrator.tar.gz
+RUN mkdir -p /build/contextual-orchestrator \
+ && tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 \
+ -C /build/contextual-orchestrator \
+ && rm /tmp/contextual-orchestrator.tar.gz
+WORKDIR /build/contextual-orchestrator/rust/token_counter
+RUN maturin build --locked --release --out /build/wheels \
+ && set -- /build/wheels/*.whl \
+ && test "$#" -eq 1 \
+ && test -f "$1"
+
FROM python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf
WORKDIR /app
+ARG CONTEXTUAL_ORCHESTRATOR_SOURCE_REVISION=unknown
+LABEL org.opencontainers.image.revision=${CONTEXTUAL_ORCHESTRATOR_SOURCE_REVISION}
+
# 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/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' \
+COPY requirements.lock /tmp/orchestrator-requirements.lock
+COPY --from=token-builder /build/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator
+COPY --from=token-builder /build/contextual-orchestrator/examples /app/examples
+COPY --from=token-builder /build/wheels /tmp/token-wheels
+RUN python -m pip install --no-cache-dir --require-hashes \
+ -r /tmp/orchestrator-requirements.lock \
+ && rm /tmp/orchestrator-requirements.lock \
+ && set -- /tmp/token-wheels/*.whl \
+ && test "$#" -eq 1 \
+ && test -f "$1" \
+ && python -m pip install --no-cache-dir --no-deps "$1" \
+ && cp /usr/local/lib/python3.12/site-packages/contextual_orchestrator/_token_packer*.so /app/contextual_orchestrator/ \
+ && rm -rf /tmp/token-wheels \
&& useradd --uid 10001 --no-create-home orchestrator
COPY agents.json /app/agents.json
COPY start.py /app/start.py
+COPY verify_startup_contract.py /app/verify_startup_contract.py
+RUN python /app/verify_startup_contract.py \
+ && rm /app/verify_startup_contract.py
ENV AGENTS_FILE=/app/agents.json \
PORT=8000
diff --git a/docker/contextual-orchestrator/requirements.in b/docker/contextual-orchestrator/requirements.in
new file mode 100644
index 000000000..1c0fbed60
--- /dev/null
+++ b/docker/contextual-orchestrator/requirements.in
@@ -0,0 +1,3 @@
+opentelemetry-api==1.44.0
+opentelemetry-sdk==1.44.0
+opentelemetry-exporter-otlp-proto-http==1.44.0
diff --git a/docker/contextual-orchestrator/requirements.lock b/docker/contextual-orchestrator/requirements.lock
new file mode 100644
index 000000000..9ba761162
--- /dev/null
+++ b/docker/contextual-orchestrator/requirements.lock
@@ -0,0 +1,578 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile /tmp/contextual-4db-upstream.txt docker/contextual-orchestrator/requirements.in --generate-hashes --universal --output-file docker/contextual-orchestrator/requirements.lock
+attrs==26.1.0 \
+ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
+ --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # jsonschema
+ # referencing
+certifi==2026.7.22 \
+ --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
+ --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # requests
+cffi==2.1.1 ; platform_python_implementation != 'PyPy' \
+ --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \
+ --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \
+ --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \
+ --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \
+ --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \
+ --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \
+ --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \
+ --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \
+ --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \
+ --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \
+ --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \
+ --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \
+ --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \
+ --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \
+ --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \
+ --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \
+ --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \
+ --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \
+ --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \
+ --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \
+ --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \
+ --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \
+ --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \
+ --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \
+ --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \
+ --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \
+ --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \
+ --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \
+ --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \
+ --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \
+ --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \
+ --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \
+ --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \
+ --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \
+ --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \
+ --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \
+ --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \
+ --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \
+ --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \
+ --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \
+ --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \
+ --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \
+ --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \
+ --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \
+ --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \
+ --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \
+ --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \
+ --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \
+ --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \
+ --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \
+ --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \
+ --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \
+ --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \
+ --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \
+ --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \
+ --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \
+ --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \
+ --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \
+ --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \
+ --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \
+ --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \
+ --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \
+ --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \
+ --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \
+ --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \
+ --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \
+ --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \
+ --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \
+ --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \
+ --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \
+ --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \
+ --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \
+ --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \
+ --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \
+ --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \
+ --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \
+ --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \
+ --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \
+ --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \
+ --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \
+ --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \
+ --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \
+ --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \
+ --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \
+ --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \
+ --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \
+ --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \
+ --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \
+ --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \
+ --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \
+ --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \
+ --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \
+ --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \
+ --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \
+ --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \
+ --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \
+ --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \
+ --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \
+ --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \
+ --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # cryptography
+charset-normalizer==3.5.1 \
+ --hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \
+ --hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \
+ --hash=sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5 \
+ --hash=sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b \
+ --hash=sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f \
+ --hash=sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f \
+ --hash=sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5 \
+ --hash=sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22 \
+ --hash=sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5 \
+ --hash=sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac \
+ --hash=sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838 \
+ --hash=sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90 \
+ --hash=sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626 \
+ --hash=sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4 \
+ --hash=sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369 \
+ --hash=sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b \
+ --hash=sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e \
+ --hash=sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee \
+ --hash=sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1 \
+ --hash=sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102 \
+ --hash=sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8 \
+ --hash=sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8 \
+ --hash=sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9 \
+ --hash=sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf \
+ --hash=sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0 \
+ --hash=sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031 \
+ --hash=sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e \
+ --hash=sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235 \
+ --hash=sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072 \
+ --hash=sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb \
+ --hash=sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c \
+ --hash=sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950 \
+ --hash=sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2 \
+ --hash=sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb \
+ --hash=sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e \
+ --hash=sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6 \
+ --hash=sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b \
+ --hash=sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2 \
+ --hash=sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d \
+ --hash=sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa \
+ --hash=sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2 \
+ --hash=sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818 \
+ --hash=sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032 \
+ --hash=sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71 \
+ --hash=sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96 \
+ --hash=sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687 \
+ --hash=sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8 \
+ --hash=sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3 \
+ --hash=sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61 \
+ --hash=sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9 \
+ --hash=sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1 \
+ --hash=sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a \
+ --hash=sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420 \
+ --hash=sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4 \
+ --hash=sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65 \
+ --hash=sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663 \
+ --hash=sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f \
+ --hash=sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591 \
+ --hash=sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a \
+ --hash=sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18 \
+ --hash=sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e \
+ --hash=sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f \
+ --hash=sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7 \
+ --hash=sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3 \
+ --hash=sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c \
+ --hash=sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3 \
+ --hash=sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7 \
+ --hash=sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96 \
+ --hash=sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486 \
+ --hash=sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3 \
+ --hash=sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6 \
+ --hash=sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b \
+ --hash=sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731 \
+ --hash=sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959 \
+ --hash=sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9 \
+ --hash=sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf \
+ --hash=sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8 \
+ --hash=sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e \
+ --hash=sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f \
+ --hash=sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885 \
+ --hash=sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0 \
+ --hash=sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506 \
+ --hash=sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2 \
+ --hash=sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0 \
+ --hash=sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e \
+ --hash=sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f \
+ --hash=sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e \
+ --hash=sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491 \
+ --hash=sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a \
+ --hash=sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20 \
+ --hash=sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449 \
+ --hash=sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af \
+ --hash=sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c \
+ --hash=sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712 \
+ --hash=sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7 \
+ --hash=sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a \
+ --hash=sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20 \
+ --hash=sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f \
+ --hash=sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3 \
+ --hash=sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9 \
+ --hash=sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e \
+ --hash=sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5 \
+ --hash=sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2 \
+ --hash=sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36 \
+ --hash=sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263 \
+ --hash=sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4 \
+ --hash=sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11 \
+ --hash=sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a \
+ --hash=sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3 \
+ --hash=sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375 \
+ --hash=sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa \
+ --hash=sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d \
+ --hash=sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5 \
+ --hash=sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99 \
+ --hash=sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d \
+ --hash=sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c \
+ --hash=sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488 \
+ --hash=sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6 \
+ --hash=sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc \
+ --hash=sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b \
+ --hash=sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f \
+ --hash=sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00 \
+ --hash=sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10 \
+ --hash=sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598 \
+ --hash=sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6 \
+ --hash=sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962 \
+ --hash=sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c \
+ --hash=sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08 \
+ --hash=sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab \
+ --hash=sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573 \
+ --hash=sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90 \
+ --hash=sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5 \
+ --hash=sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18 \
+ --hash=sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d \
+ --hash=sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af \
+ --hash=sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea \
+ --hash=sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c \
+ --hash=sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b \
+ --hash=sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6 \
+ --hash=sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8 \
+ --hash=sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774 \
+ --hash=sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004 \
+ --hash=sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a \
+ --hash=sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a \
+ --hash=sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2 \
+ --hash=sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2 \
+ --hash=sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa \
+ --hash=sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe \
+ --hash=sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3 \
+ --hash=sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc \
+ --hash=sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795 \
+ --hash=sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d \
+ --hash=sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc \
+ --hash=sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893 \
+ --hash=sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef \
+ --hash=sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d \
+ --hash=sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda \
+ --hash=sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17 \
+ --hash=sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30 \
+ --hash=sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7 \
+ --hash=sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5 \
+ --hash=sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182 \
+ --hash=sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f \
+ --hash=sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9 \
+ --hash=sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada \
+ --hash=sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876 \
+ --hash=sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a \
+ --hash=sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348 \
+ --hash=sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3 \
+ --hash=sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f \
+ --hash=sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0 \
+ --hash=sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # requests
+cryptography==50.0.0 \
+ --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \
+ --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \
+ --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \
+ --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \
+ --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \
+ --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \
+ --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \
+ --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \
+ --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \
+ --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \
+ --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \
+ --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \
+ --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \
+ --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \
+ --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \
+ --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \
+ --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \
+ --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \
+ --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \
+ --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \
+ --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \
+ --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \
+ --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \
+ --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \
+ --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \
+ --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \
+ --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \
+ --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \
+ --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \
+ --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \
+ --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \
+ --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \
+ --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \
+ --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \
+ --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \
+ --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \
+ --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \
+ --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \
+ --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \
+ --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \
+ --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \
+ --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \
+ --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \
+ --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \
+ --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \
+ --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645
+ # via -r /tmp/contextual-4db-upstream.txt
+googleapis-common-protos==1.75.1 \
+ --hash=sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79 \
+ --hash=sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # opentelemetry-exporter-otlp-proto-http
+idna==3.19 \
+ --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
+ --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # requests
+jsonschema==4.26.0 \
+ --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \
+ --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
+ # via -r /tmp/contextual-4db-upstream.txt
+jsonschema-specifications==2025.9.1 \
+ --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \
+ --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # jsonschema
+opentelemetry-api==1.44.0 \
+ --hash=sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a \
+ --hash=sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # -r docker/contextual-orchestrator/requirements.in
+ # opentelemetry-exporter-otlp-proto-http
+ # opentelemetry-sdk
+ # opentelemetry-semantic-conventions
+opentelemetry-exporter-otlp-proto-common==1.44.0 \
+ --hash=sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694 \
+ --hash=sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # opentelemetry-exporter-otlp-proto-http
+opentelemetry-exporter-otlp-proto-http==1.44.0 \
+ --hash=sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3 \
+ --hash=sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # -r docker/contextual-orchestrator/requirements.in
+opentelemetry-proto==1.44.0 \
+ --hash=sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56 \
+ --hash=sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # opentelemetry-exporter-otlp-proto-common
+ # opentelemetry-exporter-otlp-proto-http
+opentelemetry-sdk==1.44.0 \
+ --hash=sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b \
+ --hash=sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # -r docker/contextual-orchestrator/requirements.in
+ # opentelemetry-exporter-otlp-proto-http
+opentelemetry-semantic-conventions==0.65b0 \
+ --hash=sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb \
+ --hash=sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # opentelemetry-sdk
+protobuf==7.36.0 \
+ --hash=sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488 \
+ --hash=sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16 \
+ --hash=sha256:53374d53fc29a67f7dbbf0ade47d7526a0f0137bf0f9c90e48d8a60790ef748c \
+ --hash=sha256:70f5ec8eb0da81a44360c0dc0beac99a0d78071d21956a7076bae8bd2051841b \
+ --hash=sha256:7326fd717bdc419162a735938d89d4032332bcc3408804012b24ff3a37086071 \
+ --hash=sha256:9103532dffd80c6fab7e50c65a31007680a06eb57537d437bb1b35812c138a37 \
+ --hash=sha256:bf94a5917c71058262de683669bc0a797a7669d3de71f0b36d058e3194f47b44 \
+ --hash=sha256:e8e09cb0d794c6687926fa558a8a6e72aa10edb997d5ca61da0765f12a3e00ea
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # googleapis-common-protos
+ # opentelemetry-proto
+pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \
+ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
+ --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # cffi
+redis==8.1.0 \
+ --hash=sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25 \
+ --hash=sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb
+ # via -r /tmp/contextual-4db-upstream.txt
+referencing==0.37.0 \
+ --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \
+ --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # jsonschema
+ # jsonschema-specifications
+requests==2.34.2 \
+ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
+ --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # opentelemetry-exporter-otlp-proto-http
+rpds-py==2026.6.3 \
+ --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \
+ --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \
+ --hash=sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9 \
+ --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \
+ --hash=sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804 \
+ --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \
+ --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \
+ --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \
+ --hash=sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6 \
+ --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \
+ --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \
+ --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \
+ --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \
+ --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \
+ --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \
+ --hash=sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9 \
+ --hash=sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127 \
+ --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \
+ --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \
+ --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \
+ --hash=sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171 \
+ --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \
+ --hash=sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c \
+ --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \
+ --hash=sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4 \
+ --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \
+ --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \
+ --hash=sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f \
+ --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \
+ --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \
+ --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \
+ --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \
+ --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \
+ --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \
+ --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \
+ --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \
+ --hash=sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41 \
+ --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \
+ --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \
+ --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \
+ --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \
+ --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \
+ --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \
+ --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \
+ --hash=sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7 \
+ --hash=sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261 \
+ --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \
+ --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \
+ --hash=sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93 \
+ --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \
+ --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \
+ --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \
+ --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \
+ --hash=sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c \
+ --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \
+ --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \
+ --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \
+ --hash=sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3 \
+ --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \
+ --hash=sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826 \
+ --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \
+ --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \
+ --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \
+ --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \
+ --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \
+ --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \
+ --hash=sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90 \
+ --hash=sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7 \
+ --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \
+ --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \
+ --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \
+ --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \
+ --hash=sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9 \
+ --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \
+ --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \
+ --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \
+ --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \
+ --hash=sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4 \
+ --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \
+ --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \
+ --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \
+ --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \
+ --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \
+ --hash=sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca \
+ --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \
+ --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \
+ --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \
+ --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \
+ --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \
+ --hash=sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76 \
+ --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \
+ --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \
+ --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \
+ --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \
+ --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \
+ --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \
+ --hash=sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911 \
+ --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \
+ --hash=sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a \
+ --hash=sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9 \
+ --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \
+ --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \
+ --hash=sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278 \
+ --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \
+ --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \
+ --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \
+ --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \
+ --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \
+ --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \
+ --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \
+ --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \
+ --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \
+ --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \
+ --hash=sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da \
+ --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \
+ --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # jsonschema
+ # referencing
+typing-extensions==4.16.0 \
+ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
+ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # opentelemetry-api
+ # opentelemetry-exporter-otlp-proto-http
+ # opentelemetry-sdk
+ # opentelemetry-semantic-conventions
+urllib3==2.7.0 \
+ --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
+ --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # requests
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 01dc5d189..1830f57ca 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -8,6 +8,7 @@
from __future__ import annotations
import os
+import secrets
import sys
import json
from pathlib import Path
@@ -23,6 +24,24 @@ def _pop_first_env(*names: str) -> str:
return first
+def _configured_agents(agents: dict[str, object], provider_url: str) -> dict[str, object]:
+ """Bind seed agents to the trusted configured-gateway discovery boundary."""
+ configured = json.loads(json.dumps(agents))
+ raw_agents = configured.get("agents")
+ if not isinstance(raw_agents, list):
+ raise SystemExit("agents.json must contain an agents list")
+ for agent in raw_agents:
+ if not isinstance(agent, dict):
+ raise SystemExit("agents.json entries must be objects")
+ agent["base_url"] = provider_url
+ agent["credential_key"] = "LLM_GATEWAY_API_KEY"
+ agent["provider_name"] = "configured_gateway"
+ if not str(agent.get("model", "")).strip():
+ agent["tags"] = list(dict.fromkeys((*agent.get("tags", []), "bootstrap_seed")))
+ agent.setdefault("provider_protocol", "auto")
+ return configured
+
+
def main() -> None:
"""Register the provider credential and delegate to the upstream server."""
gateway_key = _pop_first_env("LLM_GATEWAY_API_KEY", "LLM_API_KEY")
@@ -42,12 +61,24 @@ def main() -> None:
auth_token = os.environ.get("CONTEXTUAL_ORCHESTRATOR_TOKEN", "").strip()
if not auth_token:
raise SystemExit("CONTEXTUAL_ORCHESTRATOR_TOKEN is required to start the authenticated LLM service")
+ admin_token = (
+ os.environ.pop("CONTEXTUAL_ORCHESTRATOR_ADMIN_TOKEN", "").strip()
+ or secrets.token_urlsafe(48)
+ )
provider_url = _pop_first_env("LLM_GATEWAY_API_URL", "LLM_GATEWAY_URL", "LLM_API_GATEWAY")
if not provider_url:
raise SystemExit("LLM_GATEWAY_API_URL or LLM_GATEWAY_URL is required to start the gateway")
if not provider_url.rstrip("/").endswith("/v1"):
provider_url = provider_url.rstrip("/") + "/v1"
+ allowed_provider_hosts = [
+ host.strip()
+ for host in os.environ.get(
+ "CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS", ""
+ ).split(",")
+ if host.strip()
+ ]
+ batch_registry_url = os.environ.pop("BATCH_JOB_REGISTRY_VALKEY_URL", "").strip()
raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
try:
max_output_tokens = int(raw_limit)
@@ -63,20 +94,22 @@ def main() -> None:
if not 64 * 1024 <= max_body_bytes <= 64 * 1024 * 1024:
raise SystemExit("CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES must be between 65536 and 67108864")
agents_path = Path("/tmp/lineageweave-agents.json")
- agents = json.loads(Path("/app/agents.json").read_text(encoding="utf-8"))
- for agent in agents["agents"]:
- agent["base_url"] = provider_url
- agent["credential_key"] = "LLM_GATEWAY_API_KEY"
- agent.setdefault("provider_protocol", "auto")
+ agents = _configured_agents(
+ json.loads(Path("/app/agents.json").read_text(encoding="utf-8")),
+ provider_url,
+ )
os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", None)
agents_path.write_text(json.dumps(agents), encoding="utf-8")
from contextual_orchestrator.credentials import register_credential
register_credential("LLM_GATEWAY_API_KEY", gateway_key)
+ if batch_registry_url:
+ register_credential("batch_job_registry_valkey_url", batch_registry_url)
for credential_name, credential_value in provider_credentials.items():
register_credential(credential_name, credential_value)
del gateway_key
+ del batch_registry_url
del provider_credentials
sys.argv = [
"contextual_orchestrator",
@@ -84,20 +117,25 @@ def main() -> None:
"--agents",
str(agents_path),
"--auto-discover-model-agents",
- "--allow-discovery-failures",
"--host",
"0.0.0.0",
"--port",
"8000",
"--allow-public-bind",
- "--auth-token",
+ "--production",
+ "--admin-token",
+ admin_token,
+ "--inference-token",
auth_token,
"--max-output-tokens",
str(max_output_tokens),
"--max-body-bytes",
str(max_body_bytes),
]
+ for host in allowed_provider_hosts:
+ sys.argv.extend(("--allowed-provider-host", host))
del provider_url
+ del admin_token
del auth_token
from contextual_orchestrator.__main__ import main as serve
diff --git a/docker/contextual-orchestrator/verify_startup_contract.py b/docker/contextual-orchestrator/verify_startup_contract.py
new file mode 100644
index 000000000..5e9bbd385
--- /dev/null
+++ b/docker/contextual-orchestrator/verify_startup_contract.py
@@ -0,0 +1,95 @@
+"""Build-time integration proof for the pinned gateway discovery seam."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+import contextual_orchestrator.__main__ as entrypoint
+from contextual_orchestrator.credentials import (
+ InMemoryCredentialBackend,
+ register_credential,
+ set_backend,
+)
+from contextual_orchestrator.model_discovery import DiscoveredModel
+from contextual_orchestrator.orchestrator import (
+ ModelClient,
+ TaskOrchestrator,
+ load_agents,
+)
+from contextual_orchestrator import _token_packer
+from start import _configured_agents
+
+
+def main() -> None:
+ """Prove wrapper output expands into a same-origin concrete serving pool."""
+ assert _token_packer.count_cl100k("hello") == 1
+
+ gateway_origin = "https://gateway.synthetic.example/v1"
+ configured = _configured_agents(
+ json.loads(Path("/app/agents.json").read_text(encoding="utf-8")),
+ gateway_origin,
+ )
+ with TemporaryDirectory() as directory:
+ agents_path = Path(directory) / "agents.json"
+ agents_path.write_text(json.dumps(configured), encoding="utf-8")
+ loaded = load_agents(str(agents_path))
+
+ configured_model = DiscoveredModel(
+ provider_name="configured_gateway",
+ model_id="catalog-chat-model",
+ credential_name="LLM_GATEWAY_API_KEY",
+ chat_base_url=gateway_origin,
+ auth_scheme="Bearer",
+ capabilities=("chat",),
+ )
+ unrelated_models = [
+ DiscoveredModel(
+ provider_name="synthetic_provider",
+ model_id=f"other-chat-model-{index}",
+ credential_name="SYNTHETIC_PROVIDER_KEY",
+ chat_base_url="https://other.synthetic.example/v1",
+ auth_scheme="Bearer",
+ capabilities=("chat",),
+ )
+ for index in range(20)
+ ]
+ catalog = [configured_model, *unrelated_models]
+ set_backend(InMemoryCredentialBackend())
+ register_credential("LLM_GATEWAY_API_KEY", "synthetic-secret")
+ orchestrator = TaskOrchestrator(
+ loaded,
+ client=ModelClient(
+ allowed_provider_hosts={
+ "gateway.synthetic.example",
+ "other.synthetic.example",
+ }
+ ),
+ )
+ original_discovery = entrypoint.discover_all_models
+ original_probe = entrypoint._probe_configured_gateway_structured_chat
+ entrypoint.discover_all_models = lambda _sources, **_kwargs: (catalog, [])
+ entrypoint._probe_configured_gateway_structured_chat = (
+ lambda _orchestrator, model: model.provider_name == "configured_gateway"
+ )
+ try:
+ entrypoint._auto_discover_runtime_agents(orchestrator)
+ finally:
+ entrypoint.discover_all_models = original_discovery
+ entrypoint._probe_configured_gateway_structured_chat = original_probe
+
+ active_gateway = [
+ agent
+ for agent in orchestrator.agents
+ if agent.provider_name == "configured_gateway"
+ ]
+ assert len(active_gateway) == 1
+ assert active_gateway[0].model == "catalog-chat-model"
+ assert all(agent.model for agent in orchestrator.agents)
+ candidates = orchestrator._ranked_agents("synthetic request", "worker")
+ assert active_gateway[0].id in [agent.id for agent in candidates]
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/adr/0003-fast-mlsirm-report-integration.md b/docs/adr/0003-fast-mlsirm-report-integration.md
index 11decffc0..8d4798423 100644
--- a/docs/adr/0003-fast-mlsirm-report-integration.md
+++ b/docs/adr/0003-fast-mlsirm-report-integration.md
@@ -108,9 +108,10 @@ than one large PR:
public Rust-backed prediction API (upstream PR #1279); LineageWeave must
not reproduce GRM/GPCM parameter conventions locally.
8. **Leftover evidence extensions:** unexplained leftover shipped in 2.12.26
- (ADR 0182), cross-share evidence shipped in 2.12.29 (ADR 0185), and
- reconstruction evidence is Unreleased for 2.12.31 (ADR 0201). Do not
- persist explained share, unexplained share, or another unsupported alias.
+ (ADR 0182), cross-share evidence shipped in 2.12.29 (ADR 0185),
+ reconstruction evidence shipped in 2.12.31 (ADR 0201), and leftover-map
+ explained share is governed by ADR 0266. Do not persist unexplained
+ leftover share `s` or another unsupported alias.
9. **Leftover-map axis-share slice** (ADR 0148): persist Gabriel inertia
`σ_k² / Σ_j σ_j²` of leftover-map axes 1 and 2 on the same residual
SVD. Rank-0 residuals emit two zero-share axes. Do not invent a
diff --git a/docs/adr/0024-rankweave-fusion-fail-closed.md b/docs/adr/0024-rankweave-fusion-fail-closed.md
index c03f73b45..184bfe31b 100644
--- a/docs/adr/0024-rankweave-fusion-fail-closed.md
+++ b/docs/adr/0024-rankweave-fusion-fail-closed.md
@@ -22,24 +22,26 @@ tables, and does not bind the demo IdP to production Keyverse.
1. Consume RankWeave only through `RankWeaveClient`. The default
transport raises `RankWeaveNotAvailable`. `build_rankweave_client
(disabled=False)` uses `LibraryRankWeaveTransport`, which imports
- `weighted_reciprocal_rank_fuse` inside the call so a missing
- package fail-closes.
+ both `reciprocal_rank_fuse` (the default parameter-free path) and
+ `weighted_reciprocal_rank_fuse` (the explicit weighted path) inside
+ the call so a missing package fail-closes.
2. `GET /api/rankings` (`post_read`) loads ABAC-visible posts as two
rank-only channels: temporal (newest first) and lexical (token
overlap with the synthetic demo query `pricing quote delivery`).
Hidden posts are omitted from every channel. Never invent a score.
-3. Fusion is weighted RRF with Cormack et al. (2009) η = 60 and
- Samuel et al. (2025) unequal-channel weights (`temporal` 0.25,
- `lexical` 0.75). The buyer sees 1-based `fused_rank` and the post
- title — not a TEPP theta.
+3. With no calibrated weights, fusion calls RankWeave's parameter-free
+ `reciprocal_rank_fuse` with Cormack et al. (2009) η = 60. An explicit
+ psychometrically estimated convex vector calls
+ `weighted_reciprocal_rank_fuse`. The buyer sees 1-based `fused_rank` and
+ the post title — not a TEPP theta.
4. After login, Rankings sits above Calendar. Unavailable copy is
**Rankings · RankWeave not available**. An accepted hit lists the
title; click opens that `source_post`.
5. Accepted hits also disclose owned-channel evidence (ADR 0167):
- 1-based `channel_rank` and Cormack contribution
- `weight / (η + rank)` for each channel the post actually appears
- in. Missing channels are omitted. RankWeave extra fields are
- ignored. Copy states this is not a calibrated score.
+ 1-based `channel_rank` and RankWeave-owned Cormack contribution for each
+ channel the post actually appears in. Missing channels are omitted.
+ Transport extra fields are ignored. Copy states this is not a calibrated
+ score.
## Consequences
diff --git a/docs/adr/0030-external-llm-gateway-environment.md b/docs/adr/0030-external-llm-gateway-environment.md
index fccc1636f..7f95d9d6d 100644
--- a/docs/adr/0030-external-llm-gateway-environment.md
+++ b/docs/adr/0030-external-llm-gateway-environment.md
@@ -67,6 +67,9 @@ must never be returned through a buyer-facing API or persisted failure detail.
message that tells them to retry or restore the provider configuration.
- `CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS` must explicitly allow the
hostname selected by `LLM_GATEWAY_API_URL`; wildcard allowlists are forbidden.
+ Compose reads both values from the orchestrator service's `env_file`; it must
+ not redeclare the allowlist under `environment`, because host-shell
+ interpolation would replace the runtime file's value with a local default.
- Local Compose development permits only the explicitly enumerated
`host.docker.internal:8080` text gateway and `host.docker.internal:18082`
Vision gateway when `LINEAGEWEAVE_ALLOW_LOCAL_LLM_HTTP=1`; arbitrary local
@@ -77,10 +80,11 @@ must never be returned through a buyer-facing API or persisted failure detail.
application or be assumed available on an external gateway.
- LineageWeave does not configure an embedding model. Its first batch request
omits `model`; contextual-orchestrator selects a provider-neutral embedding
- model and returns that identity on submission and polling responses.
- LineageWeave binds that identity for later batches and persists it with every
- vector. A missing or changed identity, or an incomplete vector batch, fails
- closed and cannot make post content complete.
+ model from its discovered provider catalog and returns that identity on
+ submission and polling responses. LineageWeave binds that identity for later
+ batches and persists it with every vector. A missing or changed identity, or
+ an incomplete vector batch, fails closed and cannot make post content
+ complete.
- `LLM_API_KEY`, `LLM_API_GATEWAY`, and `LLM_GATEWAY_URL` are compatibility
aliases only; `LLM_GATEWAY_API_KEY` and `LLM_GATEWAY_API_URL` are the
canonical names for
diff --git a/docs/adr/0062-semantic-unit-embedding.md b/docs/adr/0062-semantic-unit-embedding.md
index 3763caed7..3cda12895 100644
--- a/docs/adr/0062-semantic-unit-embedding.md
+++ b/docs/adr/0062-semantic-unit-embedding.md
@@ -1,6 +1,6 @@
# ADR 0062: Embed paragraph and meaning-identifiable content units
-- Status: Accepted
+- Status: Accepted; arithmetic amended by ADR 0208
- Date: 2026-08-19
## Context
@@ -21,10 +21,7 @@ post whenever the source contains more than one unit:
- sentence boundaries when the caller explicitly selects the finer unit;
- conversation-turn boundaries for sender/receiver shaped content.
-`chunked_max_similarity` embeds every selected unit through the
-contextual-orchestrator embedding channel and max-pools unit-pair similarity.
-If a source produces zero or one unit, it falls back to one whole-text
-embedding because there is no meaningful pairwise chunk comparison. Persisted
+Persisted
`post_content_unit` rows are the provenance anchor for unit-level embeddings;
`post_content_embedding` and its value rows retain model and dimension
identity. The model identity is selected and returned by
@@ -35,6 +32,11 @@ provider-specific environment variable.
No local heuristic vector or whole-document replacement is allowed when the
configured embedding channel is unavailable.
+ADR 0208 removes the production-unused local pairwise cosine/max-pooling
+experiment. A future similarity score requires a versioned Rust owner envelope;
+LineageWeave retains semantic-unit selection, authorization, provenance, and
+strict envelope validation only.
+
## Consequences
- Ontology and semantic search can attribute a match to the specific content
diff --git a/docs/adr/0070-contextual-orchestrator-upstream-integration.md b/docs/adr/0070-contextual-orchestrator-upstream-integration.md
index de06a4128..8b8d455fb 100644
--- a/docs/adr/0070-contextual-orchestrator-upstream-integration.md
+++ b/docs/adr/0070-contextual-orchestrator-upstream-integration.md
@@ -38,6 +38,15 @@ must include its own unit and integration tests and be merged through its
normal review process. LineageWeave then pins the reviewed immutable upstream
commit in its Docker build and uses only the published orchestrator contract.
+An operator may set `ORCHESTRATOR_ROUTING_ENDPOINT` at the backend, worker,
+and MCP process boundary. LineageWeave adds that opaque selector as
+`routing.endpoint` only to contextual-orchestrator requests whose parsed path
+is exactly `/v1/chat/completions` or `/v1/responses`. Existing routing fields
+are preserved; a non-object routing value or a conflicting endpoint fails
+before transport. The selector is not applied to embeddings, batch routes,
+model discovery, or other HTTP services, and an unset selector retains the
+existing automatic routing behavior.
+
Until that commit is available, the affected capability is unavailable rather
than silently routed through a local patch or a guessed model. A LineageWeave
change is complete only when the pinned upstream commit starts successfully
diff --git a/docs/adr/0071-post-scoped-llm-session-metadata.md b/docs/adr/0071-post-scoped-llm-session-metadata.md
index 44aa14e4d..d5090f63c 100644
--- a/docs/adr/0071-post-scoped-llm-session-metadata.md
+++ b/docs/adr/0071-post-scoped-llm-session-metadata.md
@@ -7,9 +7,12 @@
Every contextual-orchestrator request made about one post carries the same
deterministic `lineageweave_post_session_id` in the existing OpenAI-compatible
-`metadata` object. The ID is derived from `post_id` with a LineageWeave-only
-UUID namespace; it is not a database key and does not require a
-`user_account + post_id` table.
+`metadata` object and, for POST requests, as the top-level orchestrator
+`session_id`. The correlation header defined by ADR 0122 carries that same
+value. The ID is derived from `post_id` with a LineageWeave-only UUID namespace;
+it is not a database key and does not require a `user_account + post_id` table.
+An explicitly supplied top-level value must equal the active post session;
+the transport rejects a mismatch instead of silently splitting provenance.
The same metadata object carries non-body provenance hints when available:
PU, author account ID, corporate-entity code, and source author/company,
@@ -30,3 +33,5 @@ be implemented by runtime monkey patching or by reusing a workflow run ID.
not an implicit conversation-memory store.
- Posts without a post scope, such as global Ask Agent, do not receive a fake
post session ID.
+- Provider-neutral payloads sent to services other than contextual-orchestrator
+ do not receive the orchestrator-only top-level `session_id` field.
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 1ad14cade..62f5bfbba 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,9 +15,24 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-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.
+commit `c25712646bb25d0d30e4a5146ca9ea54669dfdf6` from upstream PR #990.
+That stack combines the merged endpoint contract from PR #988 with the
+single-copy workflow prompt contract from PR #987.
+The candidate pin supplies endpoint-scoped Chat Completions and Responses
+routing. It rejects an unavailable requested endpoint instead of silently
+using another provider, and its structured gateway probe satisfies the
+provider's JSON-object request contract. The replacement retains
+provider-backed embedding batches on the current authentication and
+provider-error taxonomy. PR #990 remains open, so neither the candidate pin
+nor local runtime evidence is
+protected upstream release evidence. 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.
+The Docker builder verifies that archive against its committed SHA-256 before
+extracting it. Runtime Python packages and every transitive dependency are
+installed only from `docker/contextual-orchestrator/requirements.lock` with
+pip's `--require-hashes`; `requirements.in` records the three direct roots and
+the lock-generation command is embedded in the generated artifact.
The runtime contract is:
@@ -32,18 +47,48 @@ The runtime contract is:
- Multimodal synthesis excludes embedded image/base64 payloads from its textual
reconciliation prompt; independent VISION worker evidence is retained instead.
- A provider 4xx is reported as a failed orchestration attempt, never as a
- successful empty semantic result.
+ successful empty semantic result. HTTP 429 becomes a bounded admission
+ deferral only when the positive integer `Retry-After` header exactly matches
+ `error.detail.retry_after_seconds`; malformed or conflicting responses fail
+ closed.
- An empty seed model is expanded from the configured gateway `/v1/models`
- endpoint; embedding-only rows are not added to the chat agent pool.
+ endpoint; embedding-only rows are not added to the chat agent pool. The
+ bootstrap passes every runtime `CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS`
+ entry through the orchestrator's repeated `--allowed-provider-host` contract
+ so configured-gateway discovery cannot silently disappear from the pool.
+- Chat Completions and Responses may constrain routing to an exact configured
+ endpoint identity; the selector is never forwarded to a provider and is not
+ applied to embeddings or deferred batch work.
- A batch embedding request may omit `model`; contextual-orchestrator selects
an embedding-capable model and returns its identity for subsequent batches.
- `json_object`, `json_schema`, and Responses JSON formats run conduct plus
synthesis. Tool requests never silently fall back to one agent.
+- Asynchronous provider-readiness jobs declare the positive integer polling
+ cadence derived from the server's configured admission window; consumers do
+ not invent a polling interval.
+- One candidate's bounded probe failure records that candidate as not ready;
+ it does not discard successful readiness evidence from other candidates.
+- When persisted readiness admits a structured workflow but every candidate
+ transport fails, the request returns the typed `503 no_viable_agent` and
+ bounded `Retry-After` contract. It never exposes provider detail or reports
+ the exhausted request as an internal server error.
## Consequences
- Local Compose runtime and the reviewed upstream PR use the same orchestrator
implementation.
- Rebuilding the image is required after the upstream pin changes.
+- Promotion uses `scripts/promote_contextual_orchestrator.sh`. It starts the
+ exact labeled candidate as an isolated Compose container, then asks that
+ candidate's authenticated readiness API to probe only active
+ `configured_gateway` structured agents using the server-declared polling
+ cadence. The canonical service is recreated only after at least one such
+ agent is ready. Missing credentials, endpoint drift, authentication failure,
+ or an unavailable readiness carrier therefore leaves the existing healthy
+ canonical service untouched. The candidate consumes the current
+ `${HOME}/.env` through the existing Compose `env_file`; the promotion path
+ never copies, prints, or introduces another credential source.
+- Updating the upstream pin or an OpenTelemetry root requires review of the
+ new archive digest and regeneration of the complete hash lock.
- Protected-branch review and merge remain external gates; this pin does not
bypass upstream review.
diff --git a/docs/adr/0098-valkey-backed-post-content-ingestion.md b/docs/adr/0098-valkey-backed-post-content-ingestion.md
index 4077e398d..c1870303f 100644
--- a/docs/adr/0098-valkey-backed-post-content-ingestion.md
+++ b/docs/adr/0098-valkey-backed-post-content-ingestion.md
@@ -32,6 +32,28 @@ placed in a stream message.
permits three attempts, then records terminal
`post_content_ingestion_attempt_limit`; duplicate wake-ups cannot reopen a
terminal failure. A changed source digest starts a new budget.
+ Recovery walks the ready ledger with the deterministic
+ `(eligible_at, post_id)` keyset and wraps only after reaching the end. The
+ derived `eligible_at` is the row's existing eligibility instant: an
+ explicit `next_attempt_at`, `queued_at` for an initial attempt,
+ `queued_at + five minutes` for a retry without an explicit instant, or
+ `started_at + fifteen minutes` for a stale running lease. The same derived
+ value is used by both the due predicate and cursor ordering, so a retry that
+ becomes due after the cursor advanced remains ahead of that cursor. It must
+ not repeatedly publish only the first bounded page while later rows starve.
+ The cursor advances only through the contiguous successfully published
+ prefix; a Valkey failure leaves the first unpublished row eligible for the
+ next recovery cycle instead of postponing it until a full wrap.
+ The worker trims the Valkey stream through its consumed cursor. Producers
+ retain the existing approximate 1,000-entry bound so a worker outage cannot
+ grow the non-authoritative transport without limit; if that bound drops an
+ unread wake-up, fair ledger recovery republishes its row on a later page.
+ This cursor contract has exactly one process owner. The worker process must
+ acquire its PostgreSQL session advisory lease before starting any durable
+ consumer; a second replica fails closed before it can read or trim the
+ stream. Shutdown cancels and joins every consumer before releasing that
+ lease. Horizontal worker replication requires a successor ADR and a native
+ consumer-group acknowledgement contract.
4. The worker reuses the existing contextual-orchestrator client factories for
VISION, structure, and embeddings. It preserves one post session and the
bounded provenance metadata from `llm_context`; no raw provider call, model
@@ -83,6 +105,17 @@ normalized PostgreSQL ledger is scanned and queued/stale rows are republished
after the cursor is established. This prevents a restart from replaying an
unbounded historical stream before processing current work.
+Within one worker lifetime, the recovery keyset cursor advances by effective
+eligibility across every ready queued or stale-running lease and wraps at the
+end. This is publication
+reachability, not a change to retry order, attempt budgets, or provider
+admission. Wake-up cleanup is consumption-bound while the worker is available:
+a successful batch advances the consumer cursor and then removes entries
+through that cursor. During an outage, the pre-existing producer bound limits
+transport growth. PostgreSQL remains authoritative, so a wake-up removed by
+that bound is recovered by the advancing keyset rather than being lost behind
+page one.
+
Lease recovery also fences completion by `attempt_count`. A worker whose
15-minute lease was reclaimed may finish after the replacement worker has
started; its success, retry, or terminal failure transition is accepted only
@@ -92,11 +125,111 @@ event.
## Corpus backfill (2026-08-20)
-Operational backfill MUST use `scripts/queue_post_content_backfill.py`. It
-selects only non-draft, non-deleted rows with real source context, records the
-same completeness-aware job state in PostgreSQL, and publishes wake-ups through
-Valkey. Direct provider calls are not a substitute for the worker queue.
+Operational backfill MUST use `scripts/queue_post_content_backfill.py` or
+`POST /api/post-content/backfill`; both call the same producer. The HTTP
+entry point requires `post_admin`, accepts only a 1--200 row page, and returns
+HTTP 202 after committing the ledger and attempting wake-ups; it never runs a
+provider in the request. Each worker recovery cycle also persists one bounded
+page before republishing queued wake-ups. Active and terminal jobs remain
+excluded, so successive cycles make durable corpus progress without duplicate
+work or an unbounded HTTP request. Candidate selection and broker recovery are
+independent: either failure is recorded and retried on the next cycle without
+stopping the worker.
+
+The bounded candidate scan uses the partial
+`source_post_content_backfill_candidate_idx` on the candidate query's event-time
+fallback and deterministic tie-breakers. Its partial predicate excludes drafts
+and deleted rows; the query retains the shared source-context predicate. This
+lets PostgreSQL stop after the requested ordered page instead of evaluating
+content completeness across the whole source corpus. It does not change
+eligibility or completeness semantics.
+
+The CLI retains the same per-query bound. `--all-pages` repeats that governed
+producer until the current candidate set is empty; progress remains visible in
+the normalized job ledger after every page. Terminal failures are never reset
+implicitly. `--retry-failed` and `--all-pages` are mutually exclusive: no
+measured capacity envelope proves that resetting an entire terminal corpus is
+safe. After restoring the failed dependency, an operator retries one bounded
+page, observes aggregate worker, PostgreSQL, Valkey, and orchestrator health
+until that page settles, and only then chooses whether to admit another page.
+Each failed page uses the existing explicit retry transition and commits before
+its wake-ups.
+
+The post-content consumer remains serial within the sole leased worker. Low
+local CPU or memory use during provider waits is not a concurrency capacity
+measurement: the database pool is shared with the lease and other durable
+consumers, and the configured gateway has no measured post-content concurrency
+envelope. Operators therefore MUST NOT infer a parallelism constant from idle
+hardware or queue depth. In-process concurrency requires a measured provider
+and database capacity envelope plus deterministic tests that preserve
+attempt-count fencing, per-post session lineage, stream-cursor advancement, and
+trim-after-settlement semantics. Until that evidence exists, bounded pages may
+take one provider deadline per attempted record and this is an explicit
+throughput limitation, not permission to admit another page.
+
+The producer applies `SOURCE_POST_ELIGIBILITY_SQL`, locks source rows with
+`SKIP LOCKED`, selects only new or incomplete-succeeded jobs, rechecks the
+shared completeness predicate, and records the existing job state in
+PostgreSQL. Repeated calls therefore do not reset active or terminal work.
+When contextual-orchestrator evidence is required, an otherwise complete
+successful job with no `operations_case_analysis` row is also incomplete and
+eligible for the same bounded requeue. This lets records completed before the
+operations extractor was deployed enter that extractor without a synchronous
+provider call or a second queue.
+If Valkey is unavailable, the response reports `recovery_pending` and the
+committed queued rows are republished by the existing recovery sweep. Direct
+provider calls are not a substitute for the worker queue.
+
+## Provider admission deferral (2026-08-26)
+
+Contextual-orchestrator may return its typed `no_viable_agent` response before
+any provider inference is admitted. It supplies the same positive delay in the
+standard `Retry-After` header and its bounded error contract. This outcome is
+queue admission evidence, not a provider attempt or a negative analysis.
+
+The owning worker therefore uses a fenced PostgreSQL transition from the exact
+running lease back to queued, reverses only that lease's claim increment, and
+stores `next_attempt_at` from the orchestrator's exact delay. The post identity,
+body digest, post-scoped session, and existing evidence remain unchanged. A
+stale worker cannot defer a newer lease. Recovery publishes the row only after
+`next_attempt_at`; other transport, provider, validation, and persistence
+failures retain the existing three-attempt accounting. Raw upstream error text,
+agent identity, prompt, and response are neither stored nor shown to a reader.
+
+Operations-case analysis is the Dashboard acceptance channel and runs before
+optional product extraction inside a claimed job. Each channel commits through
+its own existing persistence transaction while retaining the same post-scoped
+session and exact body digest. A later product extraction failure therefore
+cannot erase an already committed operations case, and product latency cannot
+delay admission of the case request. This is execution isolation, not a new
+queue or a change to either channel's evidence contract.
+
+Every failed attempt persists bounded diagnostic provenance on the normalized
+job ledger: the channel stage, bounded exception class, HTTP status, orchestrator error code, explicit
+retryability when supplied by the upstream contract, and the existing
+post-scoped session correlation id. These fields support aggregate operations
+and exact-session tracing without retaining a response body, error message,
+prompt, provider identity, credential, or source text. The buyer-facing status
+continues to state the next action; these implementation diagnostics remain an
+authorized operational boundary.
+Operations-case validation failures additionally retain only the closed
+`operations_case_evidence_contract` code and `$.cases` JSON path; returned
+content is never copied into the ledger.
### Operational timeout for structure adjudication
The contextual-orchestrator structure adjudication request uses a 600-second client timeout by default. Structure inference is an accuracy-critical, structured multi-agent operation rather than a user-facing synchronous request; the longer bound prevents a slow but valid workflow from being downgraded to `unresolved` merely because the client abandoned the response. The durable job remains queued until all non-image units have complete structure evidence.
+
+### Worker supervision liveness (2026-09-01)
+
+Provider latency must not suspend Valkey consumption or the durable-ledger
+recovery sweep. The worker therefore supervises three tasks: one stream reader,
+one recovery loop, and exactly one provider processor. The reader uses the
+existing ten-entry stream batch as its queue bound. This is not a new provider
+or database parallelism policy: provider work remains serial, and duplicate
+wake-ups still pass through the PostgreSQL claim/idempotency boundary, until
+independent capacity and lease evidence supports a different decision.
+
+PostgreSQL claim and expected-attempt fencing remain authoritative. Cancelling
+the worker cancels all three supervised tasks; after restart, the stream-tail
+cursor and durable queued/stale-row recovery rules above remain unchanged.
diff --git a/docs/adr/0100-major-event-requester-processor.md b/docs/adr/0100-major-event-requester-processor.md
index c6e9d49b9..ba6efcc08 100644
--- a/docs/adr/0100-major-event-requester-processor.md
+++ b/docs/adr/0100-major-event-requester-processor.md
@@ -43,7 +43,7 @@ links.
## Related
-- [ADR 0006](0006-provenance-role-responsibility.md)
-- [ADR 0052](0052-semantic-post-summary-contract.md)
+- [ADR 0006](0006-role-responsibility-agent-ontology.md)
+- [ADR 0052](0052-plain-orchestrator-semantic-evidence.md)
- [ADR 0076](0076-paper-grounded-model-policy.md)
- W3C. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/
diff --git a/docs/adr/0115-explicit-terminal-content-retry.md b/docs/adr/0115-explicit-terminal-content-retry.md
index 8a75c8dc6..ba5290256 100644
--- a/docs/adr/0115-explicit-terminal-content-retry.md
+++ b/docs/adr/0115-explicit-terminal-content-retry.md
@@ -39,4 +39,4 @@ backfill.
## References
-- [ADR 0098: Durable post-content ingestion](0098-durable-post-content-ingestion.md)
+- [ADR 0098: Durable post-content ingestion](0098-valkey-backed-post-content-ingestion.md)
diff --git a/docs/adr/0122-otel-session-observability.md b/docs/adr/0122-otel-session-observability.md
index 01865559d..69a31c48e 100644
--- a/docs/adr/0122-otel-session-observability.md
+++ b/docs/adr/0122-otel-session-observability.md
@@ -25,12 +25,17 @@ must not be cited as protected organization evidence.
endpoints. The service resource name is lineageweave unless the operator
overrides it with the standard OTEL_SERVICE_NAME variable. A blank or unset
endpoint leaves the SDK unconfigured so a later operator value can still
- enable export.
+ enable export. Correlated Python logs use the maintained
+ `opentelemetry-instrumentation-logging` handler with the same explicit
+ `LoggerProvider`; the deprecated SDK `LoggingHandler` is not a runtime
+ compatibility path.
2. Every contextual-orchestrator POST carries the existing
- `lineageweave_post_session_id` as `X-LineageWeave-Session-Id`. The
- orchestrator binds it to the request context and adds it to provider spans,
- so chat, Responses, structured output, VISION, and embedding work for one
- post can be investigated together.
+ `lineageweave_post_session_id` as both the top-level payload `session_id`
+ and `X-LineageWeave-Session-Id`. The orchestrator binds it to the request
+ context and adds it to provider spans, so chat, Responses, structured
+ output, VISION, and embedding work for one post can be investigated
+ together. The post identifier remains authorized provenance metadata and
+ is not copied into the public response.
3. LineageWeave emits bounded HTTP and Valkey operation spans. HTTP client
failures follow the OpenTelemetry HTTP semantic conventions: error
responses and invalid response bodies end the client span with an error.
@@ -72,6 +77,10 @@ OpenTelemetry Authors. (n.d.). *Manual instrumentation with OpenTelemetry
Python*. Retrieved August 21, 2026, from
https://opentelemetry.io/docs/languages/python/instrumentation/
+OpenTelemetry Authors. (n.d.). *OpenTelemetry logging instrumentation*.
+Retrieved August 28, 2026, from
+https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/logging/logging.html
+
OpenTelemetry Authors. (n.d.). *Service semantic conventions*. Retrieved
August 21, 2026, from https://opentelemetry.io/docs/specs/semconv/registry/attributes/service/
diff --git a/docs/adr/0123-provider-error-boundary.md b/docs/adr/0123-provider-error-boundary.md
index 48610ebeb..a59e64e74 100644
--- a/docs/adr/0123-provider-error-boundary.md
+++ b/docs/adr/0123-provider-error-boundary.md
@@ -32,6 +32,14 @@ Missing or malformed evidence remains unavailable; it is never converted into
a fabricated negative result. Existing input-validation errors outside a
provider boundary retain their client-actionable 422 detail.
+Provider admission deferral is a narrow control exception. HTTP 503
+`no_viable_agent` and HTTP 429 `rate_limit_exceeded` become a retryable worker
+signal only when the orchestrator returns the same positive integer delay in
+both `Retry-After` and `error.detail.retry_after_seconds`. A missing,
+malformed, or conflicting value remains an ordinary unavailable response.
+This consumes the upstream contract introduced by contextual-orchestrator PR
+#907 without exposing its error body to the product surface.
+
## Consequences
- API clients receive a safe retry/configuration action rather than provider
diff --git a/docs/adr/0148-leftover-map-axis-share.md b/docs/adr/0148-leftover-map-axis-share.md
index a9d554176..946539bbf 100644
--- a/docs/adr/0148-leftover-map-axis-share.md
+++ b/docs/adr/0148-leftover-map-axis-share.md
@@ -18,9 +18,9 @@ share is a report-level property of the residual SVD, not a
post-identifying leftover score and not a second theta. Denormalizing
it onto each leftover pair would violate 3NF.
-`fast-mlsirm` still exposes no leftover-pair or leftover-map API.
-LineageWeave must not fork LSIRM or invent leftover numbers when the
-residual is rank-0.
+`fast-mlsirm` exposes the Rust-owned residual interaction-map API.
+LineageWeave must consume its singular values and shares without reproducing
+the factorization or inventing leftover numbers when the residual is rank-0.
## Decision
@@ -38,8 +38,8 @@ Cascade the rows with `report_period_score`. Axes are aggregate and
non-identifying: ABAC that hides leftover pairs does not hide axis
share. Do not store a second theta. Do not invent leftover numbers.
-The biplot lives in `lineageweave/leftover_pairs.py` so leftover tests
-do not import `period_report` or `fast_mlsirm`.
+The biplot lives in fast-mlsirm's Rust core. `leftover_pairs.py` only projects
+the returned array indices onto authorized post and criterion identifiers.
## Consequences
diff --git a/docs/adr/0166-idempotent-migration-replay-window.md b/docs/adr/0166-idempotent-migration-replay-window.md
index d28402453..ca49d222d 100644
--- a/docs/adr/0166-idempotent-migration-replay-window.md
+++ b/docs/adr/0166-idempotent-migration-replay-window.md
@@ -29,6 +29,13 @@ notation is an optional extension and cannot be required by this script.
PostgreSQL idempotency such as `IF NOT EXISTS` and `ON CONFLICT`; a migration
that cannot be made idempotent requires a migration ledger ADR before it is
added.
+- A later replayed migration that supersedes and drops an earlier index also
+ supersedes that earlier migration's create operation. The earlier file keeps
+ its sorted schema boundary but must not recreate a corpus-wide index that the
+ next file immediately drops. The current body-search example keeps the
+ `pg_trgm` extension in 0035 while 0036 solely owns the normalized search
+ indexes. This avoids a complete GIN build/drop cycle on every startup without
+ skipping the successor's correctness boundary.
- Execute each accepted file with `psql -X -v ON_ERROR_STOP=1`. A failed
migration stops startup instead of leaving a healthy-looking partial schema.
- Tests must cover the stable 0012 boundary and the idempotency of any changed
@@ -38,8 +45,18 @@ notation is an optional extension and cannot be required by this script.
Existing volumes receive migrations such as 0103, 0163, and 0164 without a
whitelist edit. Invalidly named files and the non-idempotent bootstrap family do
-not replay. This remains a bounded no-ledger design; introduce a durable
-migration ledger before any post-0011 migration needs exactly-once semantics.
+not replay. Most migrations remain native-idempotent and need no ledger.
+Migration 0230's initial source-assertion data backfill is the first exception:
+hashing every eligible source body made each otherwise-idempotent startup
+replay scan the entire corpus. The normalized `data_migration_completion`
+ledger records only that bounded backfill after its insert and repair finish in
+the same PostgreSQL transaction. An interruption rolls back both writes and
+marker, so replay retries safely. A transaction-scoped advisory lock serializes
+the marker check across concurrent startup attempts, preventing two complete
+corpus scans before either can commit. After completion, the 0230 source-post
+trigger owns every new or revised row and startup skips the historical scan.
+The ledger does not replace schema migration replay or permit application code
+to compensate for missing schema.
## References
diff --git a/docs/adr/0185-leftover-map-cross-share.md b/docs/adr/0185-leftover-map-cross-share.md
index 755523b43..6e28d36cb 100644
--- a/docs/adr/0185-leftover-map-cross-share.md
+++ b/docs/adr/0185-leftover-map-cross-share.md
@@ -2,6 +2,7 @@
**Decision status:** Draft
**Date:** 2026-08-24
+**Amended by:** [ADR 0266](0266-leftover-map-explained-share.md) (leftover-map explained share `e = R̂² / R²`)
Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md) and
[ADR 0049](0049-leftover-pair-report-ui.md).
diff --git a/docs/adr/0201-leftover-map-reconstruction.md b/docs/adr/0201-leftover-map-reconstruction.md
index 2071ad646..4f180394d 100644
--- a/docs/adr/0201-leftover-map-reconstruction.md
+++ b/docs/adr/0201-leftover-map-reconstruction.md
@@ -2,6 +2,7 @@
**Decision status:** Accepted
**Date:** 2026-08-25
+**Amended by:** [ADR 0266](0266-leftover-map-explained-share.md) (leftover-map explained share `e = R̂² / R²`)
**Amended by:** [ADR 0267](0267-leftover-map-coordinates.md)
(leftover-map coordinates ξ, ζ)
diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
index 3f9f5505d..8edcb7121 100644
--- a/docs/adr/0206-evidence-operations-dashboard.md
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -28,8 +28,16 @@ provenance.
2. Dashboard requests are bounded by an inclusive event-time period.
`source_post.event_occurred_at` is the primary clock and `created_at` is the
explicit fallback, matching ADR 0202. The response names that clock.
-3. Every count is authorization-filtered before aggregation. The API returns
- both event count and distinct post count; neither substitutes for the other.
+3. Every count is authorization-filtered before aggregation. A case Event
+ count is the number of persisted `operations_case_milestone` rows joined by
+ both `post_id` and `case_kind_code` to the classified, visible cases; post
+ count is the distinct count of those posts. A general
+ `post_summary_event` is not copied into every classification on its Post.
+ Case kinds without an explicitly cited milestone therefore report zero
+ case Events. Neither count substitutes for the other, and no event is
+ invented when a case-specific milestone is absent. The existing composite
+ primary/foreign keys keep this relation in third normal form, while the
+ case-kind/time index keeps aggregation independent of one Post hot key.
Analysis-pending and ingestion-failed post counts are disjoint: a failed
current job is shown as retryable failure, never hidden inside the pending
count or interpreted as a negative classification.
@@ -41,36 +49,80 @@ provenance.
regexes, provider-name ordering, local model selection, and hand-authored
scoring weights are prohibited.
5. Persist the result in normalized post case-analysis tables with the source
- body digest and orchestrator session/run provenance. A changed source body
- invalidates the old result and queues re-analysis through the existing
- content-ingestion lifecycle. Schema-invalid or unavailable results fail the
- job and remain retryable; they are not converted into a negative case.
+ body digest, a SHA-256 fingerprint of the exact ordered authorized evidence
+ window and context, and orchestrator session/run provenance. A changed
+ source body or input fingerprint invalidates reuse and queues re-analysis
+ through the existing content-ingestion lifecycle. Historical rows without
+ an input fingerprint are honest unknowns and re-analyze when next queued.
+ Schema-invalid or unavailable results fail the job and remain retryable;
+ they are not converted into a negative case.
6. External-information coverage is the distinct count of visible posts with
a persisted positive `external_information` classification divided by all
visible posts in the same period. The stored `vom` source code is supplied
to the orchestrator as labeled evidence, but does not replace semantic
analysis. Zero total posts yields `0`.
+ The external destination passes an API scope so non-external counts and
+ case rows are excluded at the SQL boundary, not merely hidden in the UI.
7. Qualitative rows project only persisted evidence:
project names and evidence spans, source sales-pool code/name, summary
events, requester/processor action evidence, roles, and Event Lineage links.
+ An explicit non-empty source project code remains project-membership
+ evidence when its source project name is absent. In that case the Dashboard
+ displays and groups by the exact code; it does not derive or fabricate a
+ project name. A source name, when present, remains the preferred display
+ label, followed by a stored semantic mention.
When the focal post lacks an answer, the orchestrator follows authorized
Event Lineage and semantic project evidence before concluding the fact is
absent from the authorized corpus.
The analysis input reuses the post-chat source assembler: focal post first,
then bounded Event Lineage and semantic-neighborhood posts after the same
- corporate-entity/process-unit ABAC check. Every classification and fact
+ corporate-entity/process-unit ABAC check. The semantic window includes posts
+ carrying the same persisted `post_project_mention.project_key`; display-name
+ similarity and keyword matching do not create that link. This lookup applies
+ the shared source-post publication eligibility boundary and a deterministic
+ candidate limit before graph loading. Every classification and fact
persists its evidence post id and the SHA-256 of the exact numbered input
document. A span that does not occur in that identified document rejects
the whole provider response; linked evidence is never rewritten as focal
post evidence.
8. Claim-investigation and rebid/handover panels include positively classified
cases and show extracted answers plus cited spans. A required answer that
- the source does not support is stored as an explicit missing fact, so the
- next action is collection or human correction rather than keyword guessing.
-9. Project journeys group events only by an explicit source project or stored
- semantic project mention. A multi-project post may appear in multiple
- journeys. Unbound events remain visible as unassigned evidence and are not
- attached to the nearest project.
+ the source does not support is stored in the normalized
+ `operations_case_missing_fact` relation as an explicit retry state while the
+ system searches the authorized semantic source window and re-analyzes the
+ case. The reader is not asked to attach the source manually.
+ A provider result is invalid unless every required question is represented
+ exactly once as either a cited supported fact or an explicit missing fact;
+ a fact cannot be both. Missing facts carry no invented value or evidence
+ span and inherit the analysis run and authorized-source boundary through
+ their classification parent.
+9. Project membership uses only an explicit source project or stored semantic
+ project mention. A multi-project post may appear in multiple groups;
+ unbound events remain unassigned. A chronological sort of those records is
+ only a **project-observed-event list**, not a Project Journey. Project
+ Journey starts, predecessors, branches, and transitions consume a
+ provenance-bearing TEPP TDT/CHRONOS result. Previous projects, customer
+ requests, procurement notices, negotiated/direct bidding, external
+ sensing, internal discussions, and sales leads are all admissible starts or
+ predecessors when the accepted TEPP artifact and source evidence connect
+ them. LineageWeave never chooses a fixed first stage or promotes nearest-date
+ ordering to a lineage edge.
+ A Dashboard case may open the existing Project History projection only with
+ an explicit project identity from `source_post.source_project_code` or
+ `post_project_mention.project_key`. The response carries that exact key, its
+ provenance field, and the visible post that supplied it separately from the
+ display label. A display name is never converted into a key. The selected
+ case supplies the focus post, while a bounded Dashboard period supplies the
+ server-derived end-of-day `Asia/Seoul` knowledge cutoff for its inclusive
+ `period_end`; this preserves every authorized branch in the selected period
+ without admitting records learned later. With no `period_end`, the client
+ omits the cutoff and retains the Project History endpoint's authoritative
+ current-query contract. The Project History endpoint reapplies source
+ eligibility and the caller's current corporate-entity/process-unit ABAC
+ boundary. A missing key,
+ authorization-filtered result, or failed request remains explicitly
+ unavailable; the Dashboard does not substitute its chronological observed
+ list or invent a lifecycle transition.
10. A repeat-issue result carries both the issue-pattern evidence and any
source-supported improvement action. Its Dashboard flow is As-Is evidence
to To-Be action: rebid history retrieval, originating-order/specification
@@ -103,6 +155,56 @@ provenance.
authorized, and that rank is never a psychometric measure or substitute for
TEPP. Missing estimates remain unavailable; no hand-picked weight is
introduced.
+15. Operations classifications and facts have a governed OWL/JSON-LD read
+ projection. Each case is a `prov:Entity`; each fact is an RDF-reified
+ `prov:Entity` linked to its exact cited Post by `prov:wasDerivedFrom`.
+ External-information relations carry a provider-returned, closed semantic
+ target type (`order`, `project`, `sales`, or `business_management`) and map
+ to typed ontology properties. This is not a `knowledge_graph_edge` alias:
+ PostgreSQL operations tables remain authoritative, and an older untyped
+ relation remains absent from the typed projection until re-analysis.
+16. Claim investigation and rebid/handover use an observed event-log contract
+ aligned with IEEE 1849-2023 (XES). A classification is the local analysis
+ case identifier; a milestone has a closed activity code, an exact cited
+ evidence span, its evidence post, source digest, observed instant, and named
+ clock. Cross-post business-case identity is not inferred from project,
+ similarity, proximity, or text.
+17. Claim investigation pairs `claim_received` with `cause_confirmed`.
+ Rebid/handover independently pairs `rebid_response_requested` with
+ `rebid_decision_recorded`, and `handover_started` with
+ `handover_accepted`. The database rejects a claim milestone on a
+ rebid/handover case, a rebid/handover milestone on a claim case, and every
+ milestone on the other case kinds; the same invariant applies to observed
+ and explicitly missing endpoints. Contextual-orchestrator identifies the supported
+ milestone semantics; LineageWeave assigns the instant only from that cited
+ `source_post`: `event_occurred_at` when present, otherwise the explicitly
+ labeled `created_at` fallback from ADR 0202. The model never emits a date.
+18. Each required endpoint is exactly one cited milestone or one normalized
+ missing-milestone row. Both observed endpoints produce the exact duration
+ `end - start`; start plus an explicitly missing end is `open`; a missing
+ start is `evidence_missing`. An open case has no elapsed duration because
+ no end instant was observed. Reversed observed endpoints reject the entire
+ provider result. No delay threshold, severity band, current-time endpoint,
+ imputed date, average, score, or arbitrary weight is introduced. Equal
+ source instants yield an auditable zero duration; they are not replaced by
+ an invented sub-record timestamp.
+19. The API rechecks the reader's current ABAC and source eligibility for each
+ classification, fact, and milestone evidence post before returning its
+ span. Consequently, aggregate counts exclude classifications whose cited
+ evidence is no longer authorized. The UI reports open, resolved, and
+ evidence-missing counts separately, shows exact elapsed seconds in a
+ lossless human-readable form, names each milestone's clock, and links the
+ reader to both endpoint sources. State and next action are conveyed in text
+ rather than color alone.
+20. The bounded durable content backfill prefers an eligible post with a
+ canonical `post_project_mention.ontology_iri` projection when its exact
+ queued source-body digest lacks operations analysis. `EXISTS` prevents a
+ multi-project mention fan-out from duplicating the post. The remaining
+ incomplete posts stay in the same fallback queue, ordered after that tier by
+ event time (with the ADR 0202 created-time fallback), created time, and post
+ id before the existing bounded `LIMIT` / `SKIP LOCKED` claim. Titles, body
+ keywords, source lifecycle codes, and inferred stages do not affect this
+ priority.
## Consequences
@@ -115,11 +217,78 @@ treated as a negative case.
## Verification
- Parser and persistence tests cover multi-label output, cited spans, malformed
- responses, source-digest invalidation, and unavailable orchestrator states.
+ responses, source-body and ordered evidence-window invalidation, replay-safe
+ fingerprint storage, and unavailable orchestrator states.
+- The wheel includes a packaged ontology fallback that is graph-isomorphic
+ with the authoritative published Turtle source, so installed runtimes and
+ source checkouts expose the same operations-case, external-relation, and
+ product vocabulary.
- Backend integration tests cover ABAC filtering, event-time fallback, event
versus post counts, external-information percentage, multi-project
- membership, and explicit missing facts.
+ membership, explicit missing facts, observed lifecycle endpoints, exact
+ elapsed duration, open cases with nullable elapsed time, reversed endpoint
+ rejection, and evidence-post authorization.
- Frontend tests cover period submission, navigation, empty/error states,
evidence links, keyboard semantics, and non-color status copy.
- Storybook interaction tests and authenticated browser screenshots audit the
rendered desktop and narrow layouts.
+- `scripts/accept_operations_dashboard_runtime.sh` fails closed on the exact
+ orchestrator image revision, performs the explicit structured-readiness
+ refresh only after operator opt-in, and polls that asynchronous job only at
+ the positive integer cadence declared by the orchestrator's admission
+ contract. A missing or malformed cadence is unavailable, not permission to
+ invent a local interval. This response field is owned by
+ `ContextualWisdomLab/contextual-orchestrator` PR #907; LineageWeave consumes
+ it without duplicating the rate-window calculation. The runner treats the
+ durable content ledger as resumable rather than assuming an empty queue. It
+ binds evidence to the exact worker image revision and container start instant,
+ then accepts either an eligible, current-source-digest grounded analysis
+ written by that deployment or observes both analysis and grounded aggregate
+ counts advance while an eligible queued/running item already exists. It never
+ resets, fabricates, or re-enqueues work for acceptance, and it fails closed
+ when neither form of evidence exists. Counts are distinct by post and remain
+ aggregate-only. The runner then exercises the authenticated Dashboard API and
+ rendered UI without printing source rows.
+ The same operator-declared run invokes `scripts/k6_operations_dashboard.js`
+ with explicit VUs and duration; it observes Dashboard reads only and keeps
+ its summary outside the repository. ADR 0272 now supplies the 20 ms maximum
+ read SLO that this earlier record deliberately left unset. The runner accepts
+ a release only when both functional checks and the ADR 0272 latency gate pass.
+- `scripts/accept_operations_dashboard_synthetic.sh` obtains only the local
+ synthetic Keycloak identity and makes authenticated Dashboard reads without
+ starting content analysis or calling a provider. It rejects backend, worker,
+ or frontend images whose OCI revision label is not the operator-declared
+ exact LineageWeave commit, and keeps distinct desktop/mobile screenshots,
+ browser output, and k6 evidence
+ outside the repository. An empty synthetic case list remains a valid UI/API
+ shape check; it is not evidence that grounded production cases exist.
+- `scripts/explain_post_content_backfill.py` executes the exact bounded
+ candidate SQL with `EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON)` inside a
+ rolled-back transaction. It reports only aggregate timing, buffer, temporary
+ block, node-kind, and relation-scan counts, so priority-sort, correlated
+ subquery, index, spill, and lock-path evidence is reproducible without
+ emitting source rows.
+- Backfill admission reads the ontology-backed priority tier first and reads
+ the remaining eligible tier only when fewer than the requested bounded page
+ are locked. This preserves the documented total order while avoiding a
+ corpus-wide priority `CASE` sort and its per-row correlated probes.
+ Candidate post identifiers are de-duplicated before mutation because the two
+ `READ COMMITTED` statements may observe a target moving between tiers.
+
+## References
+
+Institute of Electrical and Electronics Engineers. (2023). *IEEE standard for
+eXtensible Event Stream (XES) for achieving interoperability in event logs and
+event streams* (IEEE Std 1849-2023). IEEE Standards Association.
+https://standards.ieee.org/ieee/1849/10907/
+
+van der Aalst, W. M. P., Adriansyah, A., de Medeiros, A. K. A., Arcieri, F.,
+Baier, T., Blickle, T., Bose, J. C., van den Brand, P., Brandtjen, R., Buijs,
+J., Burattin, A., Carmona, J., Castellanos, M., Claes, J., Cook, J., Costantini,
+N., Curbera, F., Damiani, E., de Leoni, M., ... Wynn, M. (2012). Process mining
+manifesto. In F. Daniel, K. Barkaoui, & S. Dustdar (Eds.), *Business process
+management workshops* (pp. 169–194). Springer.
+https://doi.org/10.1007/978-3-642-28108-2_19
+
+World Wide Web Consortium. (2022). *Time ontology in OWL*.
+https://www.w3.org/TR/owl-time/
diff --git a/docs/adr/0208-externalize-local-mathematical-compute.md b/docs/adr/0208-externalize-local-mathematical-compute.md
index 42a5a0591..088089ba3 100644
--- a/docs/adr/0208-externalize-local-mathematical-compute.md
+++ b/docs/adr/0208-externalize-local-mathematical-compute.md
@@ -2,7 +2,7 @@
**Decision status:** Accepted
**Date:** 2026-08-25
-**Amends:** ADR 0003, ADR 0024, ADR 0064, ADR 0084, ADR 0132, ADR 0145,
+**Amends:** ADR 0003, ADR 0024, ADR 0062, ADR 0064, ADR 0084, ADR 0132, ADR 0145,
ADR 0148, ADR 0167, ADR 0168, ADR 0182, ADR 0185, ADR 0200, ADR 0201, and
ADR 0205
@@ -28,10 +28,10 @@ The ecosystem product boundaries are already sufficient:
CPU/GPU implementation before LineageWeave treats a new result as governed
numerical evidence.
-LineageWeave has no standalone canonical PRD file on this exact head. Until
-one lands, `ARCHITECTURE.md` and the accepted ADR set are the product baseline;
-this absence remains a product-documentation gap, not permission to infer a
-different responsibility.
+[`docs/product-requirements.md`](../product-requirements.md) is the canonical
+product-requirements baseline on this exact head. `ARCHITECTURE.md` and the
+accepted ADR set refine its responsibility boundaries; none of those records
+permits a consumer repository to infer a different numerical owner.
## Decision
@@ -54,7 +54,7 @@ different responsibility.
Missing, malformed, non-converged, mixed-snapshot, or unsupported results
fail closed. It never repairs, normalizes, estimates, or substitutes a
numerical result.
-5. **No big-bang rewrite.** Existing local computation is frozen as named
+5. **No big-bang rewrite.** Remaining local computation is frozen as named
migration debt in
`docs/doctoring/python-mathematical-compute-boundary-audit.md`. Each owner
contract lands and proves recovery/equivalence before the corresponding
@@ -69,6 +69,34 @@ different responsibility.
Operational bounds may remain only as disclosed resource limits and may
not determine a scientific score or ground truth.
+## Implemented migration slices
+
+- The backend dependency is immutably pinned to fast-mlsirm protected-main
+ commit `d025b7d237d8db7ca97a5611606c6285d5870895`. The TEPP-specific contract
+ proposed by closed, unmerged fast-mlsirm PR #1423 is not an owner contract
+ and is not consumed. Channel-weight estimation remains unavailable until a
+ domain-neutral owner contract lands; the legacy Python estimator remains
+ frozen migration debt and MUST NOT activate calibrated weights. No customer
+ projection exposes schema, transport, hash, TEPP, or fast-mlsirm internals.
+
+- The residual interaction map consumes fast-mlsirm's protected-main
+ `residual_interaction_map` and `polytomous_expected_response` contracts.
+ Gabriel SVD, axis inertia, distance, reconstruction, unexplained residual,
+ cross share, and coverage arithmetic were deleted from LineageWeave Python.
+ Product-side identifier attachment and closest/farthest selection remain.
+- Rankings call RankWeave's classic or convex-weighted RRF owner path and
+ project its exact channel contributions. LineageWeave no longer evaluates
+ the reciprocal-rank contribution formula. RankWeave's Rust CPU/GPU migration
+ remains open, so this slice is owner-bound but not yet final execution-contract
+ compliance.
+- The production-unused `embedding_client.cosine_similarity` and
+ `chunked_max_similarity` experiments are deleted instead of being assigned
+ a new local implementation. Persisted semantic units remain the retrieval
+ provenance boundary from ADR 0062. Active Global Ask cosine stays named
+ migration debt until an accepted retrieval owner publishes a versioned Rust
+ scoring envelope; LineageWeave will validate and persist that envelope, not
+ reproduce its vector arithmetic.
+
## Stacked delivery order
1. Owner PRs publish versioned request/result schemas, model identity,
@@ -113,4 +141,3 @@ https://doi.org/10.1007/s11336-021-09762-5
Roberts, M. E., Stewart, B. M., & Tingley, D. (2019). stm: An R package for
structural topic models. *Journal of Statistical Software, 91*(2), 1–40.
https://doi.org/10.18637/jss.v091.i02
-
diff --git a/docs/adr/0210-temporal-topic-context-influence-dashboard.md b/docs/adr/0210-temporal-topic-context-influence-dashboard.md
index e9be55d48..778a0235b 100644
--- a/docs/adr/0210-temporal-topic-context-influence-dashboard.md
+++ b/docs/adr/0210-temporal-topic-context-influence-dashboard.md
@@ -1,7 +1,8 @@
# ADR 0210: TEPP temporal topics and fast-mlsirm context influence
- Status: Accepted
-- Implementation maturity: producer-contract required; consumer projection not yet shipped
+- Implementation maturity: consumer projection and fail-closed producer delivery candidate;
+ accepted upstream numerical result unavailable
- Date: 2026-08-25
- Depends on: ADR 0132 (TEPP topic-lineage boundary), ADR 0206 (operations Dashboard)
- Upstream authorities: TEPP ADR 0012; fast-mlsirm ADR 0002 and ADR 0007
@@ -87,7 +88,15 @@ The accepted TEPP result schema must include:
LineageWeave verifies the exact snapshot and cutoff before persisting a 3NF
projection. It does not inspect TEPP's private tables or reinterpret posterior
-coordinates.
+coordinates. `topic_model_run.coordinate_kind_code` fixes one representation
+for the run; `topic_post_coordinate` stores one finite value per run, post,
+topic, and posterior-draw ordinal, and the ordinal must belong to the run's
+declared draw set. Topic-lineage and context-membership evidence
+each references a normalized `provenance_assertion` whose canonical relation is
+`prov:wasDerivedFrom`; its SHA-256 remains an integrity field rather than a
+substitute for provenance. Import materializes that assertion through
+`lineageweave.prov_o.ProvGraph` so PROV-O hierarchy and qualified-relation
+implications remain the shared standard projection.
TEPP protected main currently exposes `tepp.trsl_topic_lineage.v1`, a
digest-bound CPU-`f64` artifact containing fitted forward sequence edges and
@@ -116,7 +125,7 @@ another dimension.
### LineageWeave consumer and persistence
-Use normalized objects such as `topic_model_run`, `topic_definition`,
+Use normalized objects `topic_model_run`, `topic_definition`,
`topic_activity_interval`, `topic_lineage_relation`, `topic_post_coordinate`,
`topic_context_membership`, `topic_influence_run`, and
`topic_post_context_influence`. Large result tables are partitioned by tenant
@@ -129,6 +138,73 @@ renormalizing scores. The frontend renders an exact-value table alongside the
temporal topic view, uses text/pattern as well as color for topic state, and
supports keyboard, touch, reduced motion, narrow viewports, and screen readers.
+The durable worker submits only from the accepted, normalized
+`tepp.topic_context_posterior.v1` projection. Its TEPP run identity, immutable
+source snapshot, knowledge cutoff, producer-contract version, posterior-draw
+identity, and upstream artifact digest are required fields; its coordinates,
+memberships, and provenance must be complete. The older
+`analysis_run_topic_lineage_result` stores a distinct topic-identity/CHRONOS
+envelope with a LineageWeave-computed envelope digest, while
+`analysis_run_tepp_receipt` records calibrated-measurement transport
+acceptance. Neither is evidence for this posterior projection and their
+identifiers or digests must not be equated with it. The request contains every posterior draw and every source-derived
+business-unit, PU, team, and person membership present in the run. The run
+must cover all four dimensions, while an individual post may belong only to
+the dimensions supported by its evidence and may retain several time-valid
+slices for one context. It is content-addressed before
+the database lease is released. The worker admits only a complete Cartesian
+set of post-membership-topic rows whose request, TEPP run, snapshot, cutoff,
+membership fingerprint, producer revision, convergence, identification,
+backend parity, and artifact digest all match. It recomputes the request digest
+inside the persistence transaction so a changed input cannot receive a stale
+result. Provider work holds neither a database transaction nor a pool lease.
+Incomplete older evidence is scanned past rather than pinning the queue. An
+exact remote `Retry-After` requeues at that admitted instant; all other
+failures require an explicit operator requeue after their cause is corrected,
+so the worker never invents a retry interval.
+The deployment declares request and lease timeout seconds together. The lease
+must strictly exceed the request timeout so the operator-declared difference
+remains available for result validation and persistence. A running row becomes
+claimable only after that recorded lease expiry. Incomplete input moves to a
+typed awaiting-evidence state and is woken only by a new accepted topic model,
+analysis cutoff/snapshot binding, coordinate, definition, or
+membership event. Source snapshots themselves are immutable under ADR 0018.
+If evidence changes during computation, the stale lease is released immediately
+and the next claim rebuilds the request. Invalid optional influence transport
+configuration disables only this consumer; analysis, content, and Ask work
+continues. The deployment also declares the positive poll interval. A transient
+database claim failure waits that exact interval rather than terminating the
+shared durable-worker task.
+Each claim also receives a unique database lease token. Success, failure,
+remote defer, and changed-input release update a running row only when that
+exact token still owns it, so safety does not rely only on the process-wide
+advisory lock.
+
+LineageWeave sends the request and membership design as base64-encoded raw JSON
+artifact bytes with the SHA-256 of those exact bytes. The producer verifies and
+parses those bytes, then echoes both LineageWeave-owned opaque identities
+unchanged. The producer returns its result through the same raw-byte envelope.
+LineageWeave verifies the result bytes before UTF-8 decoding or JSON parsing and
+never reserializes producer floats to verify any digest. This avoids inventing
+a canonical-JSON dialect or depending on Python and Rust float formatting
+coincidence; adopting RFC 8785 remains unavailable until both deployed sides
+implement and pass the same official vectors.
+
+This delivery path does not make the feature available by itself. The
+configured owner endpoint must implement the domain-neutral continuous
+posterior case-deletion estimand in Rust. fast-mlsirm's crossed weighted
+multiple-membership MAP contract supplies the reusable membership design and
+identification boundary; its binary response kernel is not applied to TEPP
+coordinates. Until the continuous result contract is released, the job remains
+unconfigured or records a bounded failure and the Dashboard stays unavailable.
+
+The LineageWeave consumer projection is allowed to land before activation. In
+that state, it reports which exact producer contract is not persisted and
+returns no topic, influence, rank, or fallback value. An accepted result is
+readable only when its analysis-run scope is wholly authorized for the caller;
+filtering individual result rows after a broader fit is insufficient because
+the fitted value would still include hidden observations.
+
```mermaid
sequenceDiagram
participant Source as Authorized source snapshot
diff --git a/docs/adr/0215-global-ask-public-claim-verification.md b/docs/adr/0215-global-ask-public-claim-verification.md
index ca70f566e..59f0dd91a 100644
--- a/docs/adr/0215-global-ask-public-claim-verification.md
+++ b/docs/adr/0215-global-ask-public-claim-verification.md
@@ -31,6 +31,11 @@ carried by a cited public source. Private sources, Keyman/person facts, raw
source hints, source bodies, TEPP artifacts, fast-mlsirm artifacts, prompts,
credentials, and uncited facts never form a public query.
+ADR 0275 strengthens admission: the production queue now requires a persisted,
+PROV-O-bound public-claim envelope for an exact cited post. Question-token
+overlap is retained only as legacy library compatibility and is not a runtime
+egress decision.
+
SearXNG retrieves at most five bounded snippets for at most four claims. Result
URLs must be HTTP(S), must not be search pages, localhost, `.local`, or literal
non-global addresses, and are never fetched by LineageWeave. The untrusted
diff --git a/docs/adr/0219-tepp-terminal-result-lifecycle.md b/docs/adr/0219-tepp-terminal-result-lifecycle.md
index e100ab858..1c67fcfe5 100644
--- a/docs/adr/0219-tepp-terminal-result-lifecycle.md
+++ b/docs/adr/0219-tepp-terminal-result-lifecycle.md
@@ -1,8 +1,8 @@
# ADR 0219 — Persist TEPP acceptance and consume terminal results
-**Decision status:** Accepted on this active PR; not protected-main truth until merge
-**Date:** 2026-08-26
-**Depends on:** ADR 0022, ADR 0023, ADR 0204; TEPP PR #157
+**Decision status:** Accepted on this active PR; not protected-main truth until merge
+**Date:** 2026-08-26
+**Depends on:** ADR 0022, ADR 0023, ADR 0204; TEPP PR #157
**Refs:** LineageWeave issue #277; TEPP issues #156 and #249
## Context
diff --git a/docs/adr/0222-project-nodes-in-ontology-neighborhood.md b/docs/adr/0222-project-nodes-in-ontology-neighborhood.md
index 900b76e8e..6d31fc9ba 100644
--- a/docs/adr/0222-project-nodes-in-ontology-neighborhood.md
+++ b/docs/adr/0222-project-nodes-in-ontology-neighborhood.md
@@ -51,6 +51,9 @@ API, and UI.
subject/predicate/object chain, evidence, confidence, creation time, and
PROV derivation. It performs no database access and creates no mutable RDF
store; callers must still apply authorization before supplying a row.
+ Closed-world SHACL requires those evidence, confidence, creation-time, and
+ same-source PROV fields so a hand-authored or downstream projection cannot
+ silently discard the source binding preserved by the production projector.
## Consequences
diff --git a/docs/adr/0224-canonical-compose-project.md b/docs/adr/0224-canonical-compose-project.md
new file mode 100644
index 000000000..d4de8a02c
--- /dev/null
+++ b/docs/adr/0224-canonical-compose-project.md
@@ -0,0 +1,84 @@
+# ADR 0224: Canonical local Compose project
+
+- Status: Accepted
+- Date: 2026-08-26
+
+## Context
+
+Running the same Compose file from temporary worktrees created multiple `lw*`
+and branch-named projects. Operators could no longer tell which stack owned the
+current synthetic database, migrations, frontend, backend, identity provider,
+search, queue, and contextual-orchestrator boundary. One observed project also
+carried `TEPP_API_KEY` into the backend while the Dashboard candidate omitted
+that already-supported runtime setting.
+
+## Decision
+
+`docker-compose.yml` declares the default project name `lineageweave` and keeps
+all product services in that project: PostgreSQL, the one-shot migration,
+Valkey, SearXNG, Keycloak, contextual-orchestrator, the dedicated durable-queue
+worker, backend, and frontend.
+An isolated test may still override the name explicitly with Compose `-p`; it
+must use a disposable name and must not mutate the canonical project.
+
+The backend receives only its TEPP transport URL and TEPP API credential. The
+provider gateway credentials remain confined to contextual-orchestrator through
+the existing `${HOME}/.env` boundary. Compose cleanup uses `docker compose down`
+for an exactly identified project and never deletes named volumes by default.
+
+Identity selection remains ADR 0028/0156's exclusive choice. With a non-empty
+`KEYVERSE_ISSUER`, backend and frontend use central Keyverse and malformed or
+unbound Keyverse scope claims fail closed; the local Keycloak service is not a
+second trusted issuer. With no Keyverse issuer, standalone/local/dev/test uses
+only the synthetic `lineageweave-demo` Keycloak realm.
+
+The API process never owns a queue consumer. Instead, `backend` has a required
+`service_healthy` dependency on `backend-worker`, whose progress-based health
+probe observes its event loop. The probe reads the worker's monotonic heartbeat
+with the image's POSIX shell rather than starting and importing a Python
+process on every interval. This preserves progress detection while preventing
+concurrent health probes from amplifying container-runtime and filesystem load.
+The worker removes both the heartbeat and the probe's prior baseline before it
+publishes the first heartbeat of a process. Those files may survive a process
+or VM restart in the container writable layer while the operating system's
+monotonic clock restarts from a smaller value; monotonic samples are therefore
+compared only within one worker-process epoch and never across boots.
+Each heartbeat and probe baseline carries the same random process-epoch
+identifier beside the monotonic value. A probe compares counters only inside
+one identifier and atomically adopts a changed identifier. Consequently, a
+probe that read the prior files before startup cannot poison the new epoch by
+publishing its stale baseline after the worker reset.
+The Python and POSIX-shell parsers accept the same non-negative signed-64-bit
+counter domain. An existing malformed or out-of-domain heartbeat or baseline
+fails closed instead of becoming progress evidence. Concurrent probes publish
+their baselines through distinct same-directory temporary files and atomic
+replacement. The Python probe serializes its complete read, compare, and
+publish operation with an in-process mutex and a POSIX advisory file lock; a
+filesystem or lock failure reports unhealthy. Therefore an older observation
+cannot replace a newer published baseline or manufacture later progress.
+Consequently, targeted canonical startup such
+as `docker compose up backend` also starts the worker and does not expose an API
+that can accept durable jobs while no consumer exists. Non-Compose deployments
+must express the same co-deployment and readiness dependency in their service
+manager; process liveness alone is not durable-job readiness.
+
+Backend, worker, and frontend images carry the
+`org.opencontainers.image.revision` label supplied by the explicit
+`LINEAGEWEAVE_SOURCE_REVISION` build argument. Its default is `unknown`, so an
+acceptance runner cannot mistake an ordinary local build for exact-head
+evidence. Exact-head evidence requires a full commit SHA supplied at build time
+and verified on every participating product container before the run.
+
+## Consequences
+
+- `make up`, `make ps`, `make logs`, and `make down` address the same project
+ from the repository or a worktree unless an isolated test explicitly uses
+ `-p`.
+- A complete synthetic acceptance run can exercise OIDC, migrations, search,
+ Valkey, contextual-orchestrator, backend, frontend, Dashboard, and Ask without
+ mixing services from different working directories.
+- Starting the canonical backend target alone still starts and health-gates the
+ dedicated worker; queue ownership remains outside the HTTP process.
+- Historical `lw*` projects may be removed only after comparing their Compose
+ source and validating the canonical stack; their named volumes remain
+ recoverable.
diff --git a/docs/adr/0225-ask-answer-evidence-timeline.md b/docs/adr/0225-ask-answer-evidence-timeline.md
new file mode 100644
index 000000000..215ae8835
--- /dev/null
+++ b/docs/adr/0225-ask-answer-evidence-timeline.md
@@ -0,0 +1,60 @@
+# ADR 0225: Ask answers link citations to an evidence timeline
+
+- Status: Accepted
+- Date: 2026-08-26
+- Related: [0039](0039-global-ask-agent-source-boundary.md), [0090](0090-global-ask-lineage-timeline-expansion.md), [0153](0153-ask-evidence-layer-popup.md), [0202](0202-ask-event-time-filter.md)
+
+## Context
+
+Global Ask returns an answer and authorized cited posts, but the answer and
+source controls are visually separate. A reader cannot select citation `[2]`
+and land on the corresponding event, or select an event and return to the
+answer citation. The current response also omits the cited source's observed
+instant and named clock, so the frontend cannot construct an honest event-time
+list without guessing from the lineage graph.
+
+## Decision
+
+1. A Global Ask result returns `cited_events` in citation order. Each entry is
+ derived from the same authorized `ChatSourceDocument` that was admitted to
+ the answer and contains only its post id, title, persisted observed instant,
+ and clock code. `event_occurred_at` is preferred; `created_at` is the named
+ fallback. A missing instant remains absent.
+2. The answer renders citations `[1]..[n]` from `cited_posts`/`cited_events`.
+ Selecting a citation focuses and highlights its event card. Selecting that
+ card focuses and highlights the matching citation. Both directions preserve
+ the citation number even when cards are chronologically ordered.
+3. Every event card opens the existing evidence layer and the authorized full
+ post. The cards show the named source clock and stored evidence; they do not
+ expose provider, package, schema, hash, environment, or model-run detail.
+4. This surface is an **answer evidence timeline**, not a Project Journey.
+ Chronological ordering alone does not create a predecessor, branch, project
+ start, or causal relation. The separate Project Journey contract continues
+ to require a persisted TEPP TDT/CHRONOS result under ADR 0206.
+5. A commercial perspective or recommended response may appear only inside the
+ contextual-orchestrator answer when the cited event progression supports it.
+ The frontend never manufactures a recommendation from dates, titles, or
+ citation order. Customer copy tells the reader which evidence or source to
+ inspect next and does not explain internal implementation boundaries.
+6. The interaction uses native buttons, visible focus, `aria-pressed`, a live
+ selection status, no color-only state, and no animated scrolling. It remains
+ a single column on narrow viewports and a conversation/timeline split when
+ space permits.
+
+## Consequences
+
+- The reader can move between an answer claim and its source event without
+ losing context.
+- Event time and record time remain distinguishable without inventing dates.
+- Existing evidence-popup and post-detail authorization paths remain the only
+ source-opening paths.
+
+## Verification
+
+- Backend tests prove citation-order preservation, clock selection, absent-time
+ behavior, and unknown-citation removal.
+- Component and Storybook interaction tests prove both focus directions,
+ source opening, keyboard semantics, empty time, narrow layout, and
+ customer-facing copy.
+- Authenticated Compose screenshots cover desktop and narrow viewports with
+ synthetic data.
diff --git a/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md b/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md
new file mode 100644
index 000000000..89458c4bd
--- /dev/null
+++ b/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md
@@ -0,0 +1,183 @@
+# ADR 0226: macOS-native MLX boundary for Rust-owned computation
+
+- Status: Accepted
+- Date: 2026-08-26
+- Amends: ADR 0208 and ADR 0210
+- Clarifies: ADR 0076
+
+## Context
+
+LineageWeave runs its product services in Linux containers through Docker or
+Colima on Apple Silicon. The MLX Metal backend is not a Linux-container
+capability. MLX enables Metal on Darwin and requires Apple Silicon, macOS 14,
+Xcode 15, and the macOS 14 SDK; its Linux distributions provide CPU or NVIDIA
+CUDA backends instead. A Colima Linux VM therefore cannot truthfully issue an
+MLX Metal execution receipt merely because its macOS host has an Apple GPU.
+
+ADR 0208 assigns psychometric and scientific numerical kernels to the Rust
+cores of TEPP and fast-mlsirm. RankWeave instead owns its current
+dependency-free Python retrieval-fusion, evaluation, and audit contract; that
+contract is neither a Rust kernel nor evidence for a future Rust vector-scoring
+owner. Moving an accepted TEPP or fast-mlsirm formula into Python to gain access
+to MLX would violate its ownership boundary. ADR 0076's prohibition on
+LineageWeave-specific MLX model-provider routes remains unchanged: this ADR is
+about an already accepted owner-repository Rust kernel, not LLM, VISION,
+retrieval fusion, or an as-yet-unaccepted vector-scoring service.
+
+## Decision
+
+1. On Apple Silicon, an owner repository may execute an accepted numerical
+ kernel through MLX Metal only in a **macOS-native process**. The owner Rust
+ core remains the algorithm and contract authority and links the MLX C/C++
+ surface or an equally typed native FFI boundary. Python may launch or marshal
+ a generated binding, but it may not implement, transform, normalize, score,
+ or repair the mathematics.
+2. Linux Compose containers never claim MLX Metal execution. They call the
+ macOS-native owner service through an explicitly configured authenticated
+ HTTPS boundary reachable from the container host gateway. No endpoint,
+ credential, or certificate is baked into an image or committed. Mutual TLS
+ is required; the native service binds only to the local host interface and
+ authorizes the exact owner contract and tenant scope.
+3. The transport uses a versioned request/result envelope with input and output
+ SHA-256 digests, owner code revision, estimand and schema versions, device
+ identity, backend (`mlx_metal`, `mlx_cpu`, `mlx_cuda`, or `rust_cpu`),
+ precision, worker configuration, start/end instants, convergence and
+ identification diagnostics, and a signed execution receipt. A requested
+ Metal run without an `mlx_metal` receipt fails closed.
+4. Linux CI and non-Apple deployments may execute an owner-approved MLX CPU,
+ MLX CUDA, deterministic multithreaded Rust CPU, or owner-native Rust OpenCL
+ path. MLX does not publish an OpenCL backend, so an OpenCL receipt MUST be
+ `rust_opencl`, never `mlx_opencl`. The caller requests one exact capability;
+ runtime discovery cannot silently choose another backend. CPU portability
+ is not evidence that Metal, CUDA, or OpenCL was exercised.
+5. Every newly accelerated estimand requires deterministic synthetic recovery,
+ Rust-reference versus MLX numerical parity with the estimand's
+ identification constraints, non-finite and shape rejection, device-receipt
+ verification, disconnect/timeout/idempotency tests, and an actual
+ Apple-Silicon Metal integration run. A tolerance must come from the owner's
+ numerical error analysis and precision contract; no local constant is
+ invented by LineageWeave.
+6. LineageWeave remains a consumer. It may persist and authorize an accepted
+ receipt but never selects an MLX device, retries on a different mathematical
+ backend, or recomputes a rejected result. Customer UI presents the measured
+ result, uncertainty, evidence, and next action; it does not expose MLX,
+ Colima, FFI, transport, schema, or package details.
+7. Deployment is fail-closed and reversible. If the native service is absent,
+ untrusted, incompatible, or produces a parity-invalid result, the affected
+ channel is unavailable and dropped under the existing renormalization
+ contract. Rollback disables the native endpoint and returns to an already
+ accepted owner CPU contract; it never substitutes Python arithmetic.
+
+## Docker Compose backend contract
+
+Owner repositories publish four additive, versioned Compose overlays. The
+base product Compose file contains no accelerator device and remains the CPU-
+portable control plane. A deployment selects exactly one overlay and records
+its rendered Compose digest in the execution receipt.
+
+| Requested backend | Where computation runs | Compose/device contract | Required proof before accepting work |
+|---|---|---|---|
+| `rust_cpu` or `mlx_cpu` | Linux owner-service container | `compose.compute-cpu.yml`; no host device mapping | container CPU architecture, owner self-test, worker-count determinism, memory limit and actual backend receipt |
+| `mlx_cuda` | Linux owner-service container on an NVIDIA host | `compose.compute-cuda.yml`; Docker device reservation with `driver: nvidia`, either an explicit `device_ids` list or measured `count` (never both), and mandatory `capabilities: [gpu]` | NVIDIA driver/toolkit and MLX CUDA compatibility, selected device identity, a real CUDA kernel self-test, CPU/CUDA parity |
+| `rust_opencl` | Linux owner-service container | `compose.compute-opencl.yml`; a vendor CDI device is preferred. If CDI is unavailable, map only preflight-discovered render/compute nodes and mount the matching vendor ICD read-only; never map all of `/dev` or grant privileged mode | OpenCL platform/device identity, ICD and kernel availability, a real OpenCL kernel self-test, CPU/OpenCL parity |
+| `mlx_metal` | macOS-native Rust owner service outside Colima | no GPU device in Compose. `compose.compute-metal-host.yml` supplies only the opaque mTLS endpoint and certificate-file mounts from runtime secrets | native arm64/macOS/SDK compatibility, Metal device identity, signed native-service health, a real MLX Metal kernel self-test, CPU/Metal parity |
+
+The deployment procedure is normative:
+
+1. Run the owner-supplied preflight in **plan mode**. It reads the container
+ CPU/memory limits and enumerates only APIs available on that platform
+ (MLX device query, NVIDIA management API, OpenCL ICD, or macOS Metal). It
+ emits a machine-readable plan containing the requested backend, exact
+ device identity, driver/runtime versions, resource limits, overlay digest,
+ and failed prerequisites. It does not mutate Docker or select a fallback.
+2. Reject the plan unless the requested backend and every prerequisite are
+ satisfied. Device selection comes from an explicit administrator choice or
+ the only compatible discovered device; multiple compatible devices require
+ an explicit choice rather than catalog-order selection.
+3. Validate the rendered configuration with
+ `docker compose -f docker-compose.yml -f compose.compute-.yml config
+ --quiet`. The macOS native service must already be healthy before the Metal
+ host overlay is admitted.
+4. Start with the same files and canonical project name:
+ `docker compose -f docker-compose.yml -f
+ compose.compute-.yml -p lineageweave up -d`. Secrets and mTLS
+ material enter through runtime-only files or the platform secret store,
+ never an image, Compose literal, log, or receipt.
+5. Run the owner's device self-test and numerical parity acceptance. Only then
+ mark the backend ready. Health means that the selected device executed the
+ kernel; a process-level HTTP 200 is insufficient.
+6. On a device, driver, receipt, parity, or connectivity failure, stop
+ accepting new mathematical jobs and surface the channel as unavailable.
+ Do not restart under CPU automatically. An authorized operator may render
+ and admit the CPU overlay as a separate deployment decision.
+7. Teardown uses the exact file set and project name with `down` and never
+ removes volumes unless separately authorized. Test-only projects use an
+ isolated project name and are removed after their evidence is retained.
+
+Raw device mappings are a portability exception, not the default. The
+generated OpenCL overlay must contain the exact preflight-discovered device
+paths; a static wildcard, privileged container, host PID namespace, or broad
+device cgroup permission is prohibited. CUDA follows Docker's device
+reservation contract. CDI is used when the Docker daemon and vendor expose a
+compatible device specification because it carries device nodes, libraries,
+environment, and hooks as one auditable declaration.
+
+## Runtime topology
+
+```mermaid
+flowchart LR
+ UI[LineageWeave UI] --> API[Linux Compose API]
+ API -->|mTLS, versioned envelope| HOST[macOS-native Rust owner service]
+ HOST -->|typed native boundary| MLX[MLX Metal]
+ HOST -->|signed result and receipt| API
+ API --> DB[(Provenance store)]
+```
+
+## Consequences
+
+- Apple GPU acceleration remains available without falsely treating a Linux
+ VM as a Metal host.
+- The native service becomes a separately supervised local component with
+ certificate rotation, health, timeout, admission, audit, and resource-limit
+ responsibilities.
+- Compose stays portable. A machine without the native capability still runs
+ the product and honestly reports the affected measurement as unavailable or
+ uses a separately accepted owner CPU/CUDA result.
+- TEPP and fast-mlsirm must each adopt this boundary in their own normative ADR
+ before publishing an `mlx_metal` receipt for an accepted Rust kernel.
+- RankWeave's current Python retrieval contract is unchanged by this ADR. Any
+ future Rust vector-scoring owner requires its own accepted ownership and wire
+ contract before this accelerator boundary can apply; this ADR does not assign
+ that responsibility or require RankWeave to adopt MLX.
+
+## Alternatives considered
+
+1. **Run MLX Metal inside Colima.** Rejected because the guest is Linux and MLX
+ disables its Metal backend there.
+2. **Move the kernel into host Python.** Rejected because it transfers
+ mathematical ownership out of Rust and duplicates formulas.
+3. **Mount an unauthenticated local socket.** Rejected because VM socket
+ forwarding is runtime-specific and an unauthenticated compute boundary can
+ cross tenant and provenance scopes.
+4. **Label any Apple-hosted run as Metal.** Rejected because host hardware does
+ not prove which backend executed the operation.
+5. **Call a Rust OpenCL kernel MLX.** Rejected because MLX has no OpenCL
+ backend; backend identity is measurement provenance, not branding.
+
+## References (APA 7th)
+
+Hannun, A., Digani, J., Katharopoulos, A., & Collobert, R. (2023). *MLX: An
+array framework for Apple silicon* [Computer software]. Apple Machine Learning
+Research. https://github.com/ml-explore/mlx
+
+MLX Contributors. (2026). *Build and install: MLX 0.32.1 documentation*.
+https://ml-explore.github.io/mlx/build/html/install.html
+
+MLX Contributors. (2026). *Unified memory: MLX 0.32.1 documentation*.
+https://ml-explore.github.io/mlx/build/html/usage/unified_memory.html
+
+Docker, Inc. (2026). *Run Docker Compose services with GPU access*.
+https://docs.docker.com/compose/how-tos/gpu-support/
+
+Docker, Inc. (2026). *Container Device Interface (CDI)*.
+https://docs.docker.com/build/building/cdi/
diff --git a/docs/adr/0227-observed-postgresql-runtime-tuning.md b/docs/adr/0227-observed-postgresql-runtime-tuning.md
new file mode 100644
index 000000000..bdec9e9cc
--- /dev/null
+++ b/docs/adr/0227-observed-postgresql-runtime-tuning.md
@@ -0,0 +1,120 @@
+# ADR 0227: Observed PostgreSQL runtime tuning
+
+- Status: Accepted
+- Date: 2026-08-26
+
+## Context
+
+The canonical PostgreSQL 16 runtime has accumulated substantially more
+requested than timed checkpoints and millions of `wal_buffers_full` events.
+The currently running full-text index scan is CPU-bound and produces negligible
+new WAL, so it is not evidence for changing storage concurrency or maintenance
+memory. Historical cumulative counters are also unsafe to combine when their
+statistics-reset instants differ.
+
+Static host-size profiles and conventional memory percentages would introduce
+unsupported assumptions. PostgreSQL already supplies an automatic
+`wal_buffers` calculation, a WAL-segment boundary, a configured checkpoint
+interval, and cumulative workload counters. Those are the authoritative inputs
+for the smallest measured correction.
+
+## Decision
+
+`scripts/plan_postgres_tuning.py` is the sole LineageWeave procedure for this
+runtime tuning boundary. It performs two measurements separated by an
+operator-declared observation duration and records:
+
+- PostgreSQL version and statistics-reset instants;
+- `pg_stat_wal` and checkpoint deltas;
+- current durability and tuning settings;
+- the default and current transaction isolation levels;
+- `wal_segment_size` and the existing `checkpoint_timeout`;
+- container memory limit, data-filesystem free bytes, and current `pg_wal`
+ bytes.
+
+The planner rejects counter resets, negative deltas, unsupported PostgreSQL
+versions, incomplete durability evidence, or insufficient disk space. It emits
+an immutable JSON audit plan and a Compose environment file. It never applies a
+setting while PostgreSQL is running.
+
+The planner separately calculates WAL rates for the explicit sample and for
+PostgreSQL's own `stats_reset` to snapshot window. The calculated
+`max_wal_size` is the larger of its current value and the higher observed rate
+projected over one already-configured checkpoint interval, rounded upward to
+PostgreSQL's own WAL-segment size. This preserves historical write pressure
+when the immediate sample is a CPU-bound, zero-WAL scan and directly targets
+the documented condition in which WAL growth starts a checkpoint before
+`checkpoint_timeout`; it does not add a private safety multiplier. When neither
+window supports a larger value, `max_wal_size` remains unchanged even if the
+requested-checkpoint count is high, because that counter does not prove which
+request source caused each checkpoint.
+
+If either aligned observation window records at least one `wal_buffers_full` event,
+`wal_buffers` becomes one measured WAL segment. PostgreSQL 16 documents one WAL
+segment as the normal upper bound of its automatic selection. With no observed
+full event, the current value remains unchanged.
+
+The procedure does **not** infer `shared_buffers`, `maintenance_work_mem`,
+`effective_io_concurrency`, `maintenance_io_concurrency`, or
+`wal_compression`. Their documented trade-offs require workload-specific memory
+or storage latency/IOPS evidence that the WAL/checkpoint observation does not
+provide. A CPU-bound index scan is explicitly not storage-concurrency evidence.
+
+`fsync`, `full_page_writes`, and `synchronous_commit` must all remain enabled.
+Transaction isolation is a correctness invariant, not a WAL-throughput knob.
+The planner records both `default_transaction_isolation` and the observation
+session's `transaction_isolation`, rejects a mismatch or a change across the
+measurement/restart boundary, and never chooses a stronger or weaker level
+from WAL statistics. Any isolation-policy change requires a separate approved
+decision and concurrency evidence.
+The generated environment file is consumed only by the explicit
+`docker-compose.postgres-tuned.yml` overlay during a controlled PostgreSQL
+restart. The base Compose file remains the rollback path: remove the overlay
+and restart PostgreSQL. The JSON plan records both proposed and rollback
+values.
+
+Immediately before that controlled restart, the procedure takes a new
+PostgreSQL snapshot and new container-resource measurements. It fails closed
+unless the server major version, current WAL settings, all three durability
+settings, and both isolation settings still match the plan's rollback values;
+unless the proposed WAL reservation still fits the measured free space and
+the proposed WAL buffers fit the measured cgroup limit when one exists; and
+unless PostgreSQL reports zero other active transactions and zero ungranted
+locks. These are exact current-state gates, not inferred workload thresholds.
+Compose validation alone does not authorize a restart, and an operator must
+still provide a maintenance window that prevents new work from entering after
+the final snapshot.
+
+## Consequences
+
+- A tuning proposal is reproducible from captured measurements and contains no
+ hand-selected weights, ratios, or thresholds.
+- A short or unrepresentative observation can retain current settings but
+ cannot silently tune them.
+- Increased `max_wal_size` can lengthen crash recovery and consume more disk;
+ the plan exposes both effects and refuses a proposal whose exact additional
+ reservation exceeds observed free space.
+- Applying or rolling back requires an intentional service restart and normal
+ post-restart health/config verification.
+- A plan cannot carry old resource, correctness, or quiescence evidence across
+ the restart boundary; any mismatch requires a new observation and approval.
+
+## References
+
+PostgreSQL Global Development Group. (2026a). *PostgreSQL 16 documentation:
+20.5. Write ahead log*. https://www.postgresql.org/docs/16/runtime-config-wal.html
+
+PostgreSQL Global Development Group. (2026b). *PostgreSQL 16 documentation:
+30.5. WAL configuration*. https://www.postgresql.org/docs/16/wal-configuration.html
+
+PostgreSQL Global Development Group. (2026c). *PostgreSQL 16 documentation:
+20.4. Resource consumption*.
+https://www.postgresql.org/docs/16/runtime-config-resource.html
+
+PostgreSQL Global Development Group. (2026d). *PostgreSQL 16 documentation:
+28.2. The cumulative statistics system*.
+https://www.postgresql.org/docs/16/monitoring-stats.html
+
+PostgreSQL Global Development Group. (2026e). *PostgreSQL 16 documentation:
+54.12. pg_locks*.
+https://www.postgresql.org/docs/16/view-pg-locks.html
diff --git a/docs/adr/0228-evidence-bound-product-semantic-catalog.md b/docs/adr/0228-evidence-bound-product-semantic-catalog.md
new file mode 100644
index 000000000..79857d256
--- /dev/null
+++ b/docs/adr/0228-evidence-bound-product-semantic-catalog.md
@@ -0,0 +1,185 @@
+# ADR 0228: Evidence-bound product semantic catalog
+
+- Status: Accepted
+- Date: 2026-08-26
+- Governs: product extraction, identity resolution, typed product relations, and historical backfill
+
+## Context
+
+Product references currently remain inside source text or unrelated operational
+facts. Treating a word or tag as a product would conflate a text match with an
+identified business entity, while forcing a best match would hide homonyms.
+Imported weak or blank category/customer values remain raw source provenance,
+not final semantic categories or resolved identities.
+ADR 0184 also requires typed ontology navigation to remain distinct from Event
+Lineage. ADRs 0036, 0052, and 0206 require authorized source evidence and exact
+input provenance for semantic and operational assertions.
+
+## Decision
+
+`product_catalog` is the shared product identity across `product_group`,
+`product_model`, `variant`, and `trade_item` levels. A parent foreign key
+retains that hierarchy. Scoped GTIN and MPN identifiers live in
+`product_catalog_identifier`; an identifier without issuer scope is not an
+identity. `product_catalog_alias` is its normalized lookup vocabulary. Multiple catalog
+identities may intentionally share an alias. A contextual-orchestrator strict
+`json_schema` extraction supplies only product mentions, typed relations, and
+verbatim source spans. LineageWeave requires the non-empty top-level response
+id as the model receipt, validates each span against the authorized source,
+records its post and caller-computed SHA-256 digest, and resolves the
+normalized alias with four
+outcomes:
+
+- exactly one catalog identity: `unique`, with its foreign key;
+- no catalog identity: `missing`, without a foreign key;
+- more than one identity: `tie`, without a foreign key.
+- unavailable catalog lookup: `unavailable`, without a foreign key.
+
+Neither `missing` nor `tie` creates a catalog row. Keywords, tags, fuzzy
+thresholds, provider calls, and locally guessed identities are prohibited.
+Relations to operational facts and project mentions use foreign keys to the
+existing normalized stores. These typed relations are an ontology navigation
+projection, not Event Lineage.
+
+An authorized catalog manager provisions identity through
+`PUT /api/product-catalog/{product_code}`. Every add-only row supplies an
+explicit product code, preferred label, level, optional already-provisioned
+parent code, corporate-entity-scoped source system and source record key, and
+explicit aliases. LineageWeave calculates a canonical SHA-256 digest of that
+payload and stores it in `product_catalog_source_record`; each alias is linked
+to the same source record through `product_catalog_alias_source`. A replay of
+the same key and digest is idempotent. A changed source definition, changed
+catalog definition, missing parent, or normalized alias collision fails closed
+instead of updating identity in place. Concurrent first imports of one product
+code are serialized with a transaction-scoped database lock.
+
+These source and alias-evidence tables are third-normal-form append-only
+records. Their corporate-entity/source-record primary keys distribute ordinary
+imports, while product-first and source-first reverse indexes support both
+resolution and stewardship without a single timestamp hot key. The literal
+source category `기타` is never a product identity, alias, or evidence source
+by itself; it can become relevant only when an authorized source record
+explicitly provisions a product.
+
+An exact catalog identity projects as `CatalogProduct`, a subclass of
+`Product`, with one stable `productCatalogCode`, one
+`preferredProductLabel`, one closed `productLevelCode`, and at most one
+`parentProduct` IRI. `CatalogProductShape` validates that projection. A unique
+Post resolution returns the catalog id, code, and canonical product IRI so a
+reader can follow the same identity into ontology navigation; missing, tied,
+and unavailable outcomes return none of those bindings.
+
+The extraction request enumerates the request-scoped normalized target IDs
+that the authorized focal post may relate to. contextual-orchestrator returns
+one structured object containing mentions and relations; each relation names
+one supplied target ID, one target-kind-specific closed relation code, and a
+verbatim evidence span with its source post. LineageWeave rejects the entire
+object when a target is absent from that request, a relation code is open or
+wrong for the target kind, an ordinal is invalid, or evidence/provenance does
+not match the authorized source. Mentions and accepted relations replace the
+prior projection in one transaction. No lexical overlap between mention and
+fact/project evidence creates a relation.
+
+Product extraction is an independent post-content stage. An operations
+evidence or case-analysis failure is remembered for its own retry while the
+focal product request still runs and may persist a receipt-bearing mention
+analysis. The product request reads only the authorized focal body; it does
+not consume an operations summary or fact value to decide whether a product
+exists. Operational facts are optional typed-relation targets only when their
+own current analysis matches both the focal body digest and the exact
+authorized operations-input digest for this attempt. Project targets are
+admitted only while their stored non-empty evidence span is still verbatim in
+the current focal body. A stale target is omitted, never repaired or promoted
+from a catalog hint.
+
+Before replacement, persistence locks the focal source row and recomputes its
+body digest and the complete typed-target request digest. Operations and
+project-target replacement take the same source-row lock before invalidating
+the product completion, so a target change either rejects the in-flight result
+or removes it after that result commits. A stale provider result fails closed
+without deleting the current projection. A current completion stores the
+source digest, exact request-input digest, orchestrator session, and model
+receipt. Historical completion rows without a receipt are unavailable and are
+selected for bounded retry; they do not prove a current analysis.
+
+Replacing an operations-fact or project target invalidates the post's product
+analysis before the target projection is replaced. The durable content job
+must extract the relationship evidence again even when the replacement keeps
+the same displayed value; a cascade-deleted relation must never be mistaken
+for an already-complete analysis.
+
+Independent stages all run before the job outcome is chosen. An exact provider
+admission delay defers the attempt only when every recorded stage failure is an
+admission delay. Any ordinary validation or transport failure retains bounded
+failure accounting and cannot be hidden by a later deferred stage.
+
+Post and Dashboard reads re-apply source eligibility and ABAC to every
+relation evidence post. RDF projection uses the same normalized target and
+closed predicate and must conform to the published ProductRelationAssertion
+SHACL shape. The shape validates both the closed predicate and its subject
+kind: `usesProduct` starts at a `Project`; the four operational predicates,
+including externally sensed product evidence, start at an
+`OperationsCaseFact`. Until the contextual-orchestrator revision providing the
+owned structured-output transport is merged to its protected main and pinned
+by exact merge SHA, provider-backed relation production remains unavailable;
+local code and a branch head are not release authority.
+
+Each RDF assertion IRI includes the focal post, mention ordinal, target,
+relation code, and product identity. Those fields form the assertion identity:
+two supported predicates between the same normalized target and product remain
+two auditable assertions instead of collapsing into one invalid reification.
+
+```mermaid
+flowchart LR
+ S[source_post] -->|authorized span and digest| M[post_product_mention]
+ A[product_catalog_alias] -->|unique only| M
+ M --> P[product_catalog]
+ M --> F[operations_case_fact]
+ M --> J[post_project_mention]
+```
+
+Historical processing reuses the durable post-content queue boundary, with a
+bounded operator request and digest idempotency. HTTP requests never perform
+the extraction inline. Each post's product projection extracts only from that
+focal post's normalized source body; linked evidence remains available to
+operations inference but cannot make a sibling's product appear on the focal
+post. Publication applies the existing authorization filter
+and source eligibility predicate to both the requested post and every evidence
+post before returning the mention, relation, or evidence link. A visible post
+cannot reveal a product span cited only by evidence the reader cannot access.
+
+## Consequences
+
+- A product connection is auditable back to an exact authorized source span.
+- Catalog ambiguity remains visible and cannot silently become identity.
+- Operational and project relations reuse their existing evidence-bearing
+ normalized objects instead of duplicating unstructured values.
+- A malformed or unauthorized relation invalidates the whole extraction
+ response, so one acceptable mention cannot conceal an unsafe edge.
+- Operations outages no longer suppress an otherwise valid product mention
+ receipt; the operations stage retains its independent failure state.
+- Catalog stewardship is required before missing or tied mentions can become
+ linked products.
+- Catalog managers can now provision that stewardship evidence without a
+ model, keyword list, fuzzy match, or direct database edit.
+- High-volume deployments can partition mention and relation tables by a
+ future tenant/time key without changing their logical contract; indexes put
+ lookup keys before post identifiers to avoid one hot post partition.
+
+## Alternatives rejected
+
+- Keyword or tag classification: lexical occurrence does not establish product
+ identity or a typed business relation.
+- Model-generated catalog creation: generated identities cannot satisfy the
+ unique/miss/tie evidence boundary.
+- One polymorphic relation target column: it weakens referential integrity and
+ violates the normalized ownership of projects and operational facts.
+
+## References
+
+Bhattacharya, I., & Getoor, L. (2007). Collective entity resolution in
+relational data. *ACM Transactions on Knowledge Discovery from Data, 1*(1),
+Article 5. https://doi.org/10.1145/1217299.1217304
+
+Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*.
+World Wide Web Consortium. https://www.w3.org/TR/prov-dm/
diff --git a/docs/adr/0233-leftover-map-unexplained-share.md b/docs/adr/0233-leftover-map-unexplained-share.md
index ebbfe41b3..ba2832e10 100644
--- a/docs/adr/0233-leftover-map-unexplained-share.md
+++ b/docs/adr/0233-leftover-map-unexplained-share.md
@@ -34,7 +34,7 @@ stays auditable from persisted `R`, `R̂`, `U`, `x`, and `s`.
The unprotected-stack reconstructions for neighbouring leftover facts
use 0183 for unexplained leftover share. The dashboard stack already
-uses **0232** for leftover-map explained leftover share (PR #728) and
+uses **0266** for leftover-map explained leftover share and
**0222** for operations-case analysis input. This protected-main
increment uses **0233** (migration **0233**) so it does not collide with
GNB chrome (0183), ontology explorer (0184), leftover-map cross share
@@ -42,7 +42,7 @@ GNB chrome (0183), ontology explorer (0184), leftover-map cross share
residual disclosure, leftover observed `Y` / expected `E`, leftover-map
rank, two-axis leftover-map distance, leftover coverage, leftover-map
axis share (0148), leftover interaction-map persistence, leftover-map
-explained leftover share (0232 on the dashboard stack), or
+explained leftover share (ADR 0266), or
operations-case analysis input (0222 on that stack).
## Decision
diff --git a/docs/adr/0237-accelerator-runtime-service-boundary.md b/docs/adr/0237-accelerator-runtime-service-boundary.md
index 621a61382..ba5a37a35 100644
--- a/docs/adr/0237-accelerator-runtime-service-boundary.md
+++ b/docs/adr/0237-accelerator-runtime-service-boundary.md
@@ -1,7 +1,7 @@
# ADR 0237 — Accelerator runtimes stay behind owning service contracts
-**Decision status:** Accepted
-**Date:** 2026-08-26
+**Decision status:** Accepted
+**Date:** 2026-08-26
**Related:** ADR 0076, ADR 0083, ADR 0208
## Context
diff --git a/docs/adr/0239-external-email-project-lineage-contract.md b/docs/adr/0239-external-email-project-lineage-contract.md
index 04d9070ff..5da519287 100644
--- a/docs/adr/0239-external-email-project-lineage-contract.md
+++ b/docs/adr/0239-external-email-project-lineage-contract.md
@@ -18,15 +18,13 @@ LineageWeave publishes contract version `1.0.0` through:
The initial implementation is a store-agnostic Python package boundary. It performs no database, mailbox, provider, or network operation. A later service or Naruon plugin adapter must preserve the same JSON Schema and truth boundaries.
-Inferred edges additionally require a provenance-bearing
-`ChannelWeightEstimate` produced by the repository's fast-mlsirm measurement
-boundary. The estimate is an injected execution dependency, not caller JSON:
-the external evidence contract cannot assert its own fusion weights. When no
-estimate is available, the adapter still returns caller-observed edges and an
-explicit `channel_weights_unavailable` limitation, but produces no inferred
-edge. The LLM channel is active only when the estimate explicitly includes an
-`llm` item; an available model without such measurement remains unavailable
-for this run.
+Inferred edges remain unavailable until the measurement owner publishes an
+accepted, independently anchored fitted artifact. The external evidence
+contract cannot assert its own fusion weights, and LineageWeave does not fit,
+normalize, simulate, or interpret them in Python. The adapter returns
+caller-observed edges and an explicit `channel_weights_unavailable`
+limitation, but produces no inferred edge. Requesting the optional LLM channel
+therefore reports it unavailable and never activates a provider call.
The caller supplies opaque evidence references, bounded text labels, occurrence and availability clocks, an optional secondary key, an optional project reference, and an optional caller-observed parent relation. Explicit observed parent relations replace an inferred parent for the same child and must form an acyclic graph. Reconstructed continuation remains `inferred`. Project groupings remain `proposed`.
@@ -48,7 +46,7 @@ Evidence becoming available after the cutoff is excluded even when it describes
- RFC reply/thread evidence stays distinguishable from semantic lineage.
- Caller-observed children are never disclosed to an optional model merely to calculate an inferred edge that would be discarded.
- The optional LLM channel is explicit as `not_requested`, `unavailable`, or `completed`; missing output is never zero.
-- Missing or malformed psychometric weight provenance yields no inferred edge; no default, equal, or caller-authored weight is substituted.
+- Until an accepted owner artifact exists, no inferred edge is emitted; no default, equal, simulated, local, or caller-authored weight is substituted.
- Canonical serialization and SHA-256 digesting are deterministic for a given request or result. Repeatability of model-backed scores additionally requires a pinned LineageWeave release, adjudicator implementation, provider/model revision, and model-side determinism policy.
- Explicit parent cycles and analysis work above the caller-approved pair budget fail closed before inference.
- Project evidence can inform Naruon without mutating authoritative project/task/provider state.
diff --git a/docs/adr/0243-evidence-bound-project-history-projection.md b/docs/adr/0243-evidence-bound-project-history-projection.md
index 295bc5e85..aceb3e6c2 100644
--- a/docs/adr/0243-evidence-bound-project-history-projection.md
+++ b/docs/adr/0243-evidence-bound-project-history-projection.md
@@ -21,6 +21,17 @@ eligibility, and knowledge cutoff are applied before child evidence is read.
Project identity uses exact NFKC-normalized source or semantic evidence; no
fuzzy match is allowed.
+Candidate selection is the union of the two accepted exact key-bearing fields:
+`source_post.source_project_code` and `post_project_mention.project_key`.
+Display names never become identity. After the visible event set is selected,
+its stored names remain presentation evidence. Each key field has an index over
+the same NFKC, ASCII-edge-trimmed, case-folded expression used by the query. The
+union yields only candidate Post identities;
+source eligibility, cutoff, and current caller ABAC are still applied after the
+candidate join and before any child evidence is read. This query shape removes
+a whole-corpus identity scan without caching authorization or changing exact
+membership, ordering, or truncation.
+
The existing post-detail popup hosts the shared timeline; there is no new
navigation destination. Controlled VOC codes may label VOC evidence. Other
records remain `source_recorded`; source stage and detail-state codes are shown
diff --git a/docs/adr/0244-source-preserving-voice-semantic-taxonomy.md b/docs/adr/0244-source-preserving-voice-semantic-taxonomy.md
new file mode 100644
index 000000000..c3408eeac
--- /dev/null
+++ b/docs/adr/0244-source-preserving-voice-semantic-taxonomy.md
@@ -0,0 +1,111 @@
+# ADR 0244: Source-preserving voice semantic taxonomy
+
+- Status: Accepted
+- Date: 2026-08-26
+
+## Context
+
+The imported `source_post.voc_type_code` is provenance, not permission to
+overwrite the source or collapse organization relationships into one post
+label. The post vocabulary contains `voc`, `vocc`, `voco`, `vom`, `vop`,
+`vos`, `voe`, `vob`, `vor`, `voi`, `voso`, and `vops`; the independently
+governed post-scoped organization relationships remain `rel_voc`, `rel_vocc`,
+`rel_voco`, `rel_vom`, `rel_vop`, and `rel_vos`. A bare post-type code and a
+`rel_` counterparty code are different assertions even when their labels are
+similar. In particular, a post's voice never assigns that relationship to
+every organization named in the post.
+
+## Decision
+
+Source assertions and contextual-orchestrator-derived assertions are append-only
+and separate. Derived assertions require an exact source span, source revision
+digest, evidence digest, model receipt, and optional validity interval. A post
+or organization may have multiple simultaneous memberships. Conflicting
+source and derived concept sets remain disagreement evidence; matching
+multi-membership sets are agreement, not a pairwise mismatch. A summary admits
+an assertion only while its optional validity interval contains the query
+instant. Imported source labels have no business-event validity interval: they
+are available as provenance as soon as recorded, even when the post describes
+a future event. Optional validity intervals describe derived or explicitly
+time-scoped relationship claims, not ingestion availability. No threshold,
+weight, keyword, alias rule, or forced winner is permitted. A replacement or
+retraction names the superseded assertion and closes validity with provenance.
+The database reconciles the source assertion in the same transaction that
+inserts or changes `source_post.voc_type_code` or its revision-bearing body.
+It retains the prior assertion as a closed, superseded version; migration
+replay is a recovery/backfill path, not the normal ingestion lifecycle. The
+initial historical backfill records `0230_voice_source_assertion_backfill` in
+`data_migration_completion` only after its insert and repair finish in one
+transaction. An interrupted run therefore retries, while a completed replay
+does not repeatedly hash the source corpus; subsequent writes remain covered
+by the trigger. The trigger is installed before the backfill snapshot so a
+concurrent write cannot fall between recovery and normal ingestion coverage.
+A trigger-disabled restore must restore the assertion table with `source_post`;
+if it restores source rows alone, the operator deletes the retained
+`0230_voice_source_assertion_backfill` completion marker and replays migration
+0253 before normal writes resume.
+
+Counts use the same authorized eligible-post denominator at the same cutoff and
+filters. They report source, derived, multi-membership, disagreement, and
+unavailable counts. Per-category membership percentages divide by all eligible
+posts and disclose that overlapping category counts may exceed the denominator.
+Organization-relationship counts use a separately named evidence-bearing
+post-by-organization denominator. Filters may narrow period, corporate entity,
+PU, team, person, product, or project without changing these denominators.
+
+SHACL admits the twelve bare post codes only for post-voice assertions, admits
+the six `rel_` codes only in the organization-relationship scheme, and requires
+derived evidence/digest/receipt/time fields.
+Raw `source_post.voc_type_code` is never updated by this projection.
+
+The post-content worker produces the derived side through one strict
+`json_schema` contextual-orchestrator request over the already-authorized focal
+Post body. The schema admits only the twelve governed post codes and allows one
+assertion per supported code, so the response is multi-label without creating a
+compound code or forced winner. Every item supplies character offsets; the
+consumer accepts it only when those offsets select the returned verbatim span
+from the exact submitted body. The consumer computes both digests itself. It
+never searches for a similar span, repairs an offset, assigns a default, or
+promotes source metadata into evidence.
+
+The non-empty top-level orchestrator response id is the model receipt. A
+missing id, malformed schema result, unsupported or duplicate code, invalid
+offset, or stale source revision fails closed and leaves the derived analysis
+unavailable for bounded retry. A valid empty assertion array is different: its
+receipt and current source digest are persisted in
+`post_voice_classification_analysis` with assertion count zero. That completion
+row prevents repeated inference while retaining zero positive derived
+assertions. A later source revision invalidates completion by digest mismatch;
+replacement persistence closes prior derived assertions and links same-code
+successors without rewriting source assertions.
+
+The completion relation is one current row per Post and the assertion relation
+is indexed by Post, validity, and concept. That shape is third-normalized and
+bounded by the Post corpus plus retained assertion history. It is not
+partitioned without measured write, retention, or lock pressure; the existing
+scope/history indexes are the required access path until such evidence exists.
+
+Voice classification is an independent post-content stage. It runs before the
+operations evidence/case stage and persists transactionally, so an operations
+failure cannot suppress an otherwise valid Voice receipt. A Voice failure is
+remembered while the other independent stages continue; the job succeeds only
+after the Voice stage has a current successful receipt. Product and operations
+facts remain governed by their own evidence contracts and are never fabricated
+to compensate for either stage.
+
+## Consequences
+
+- Operators can compare original and derived semantics without losing either.
+- Category totals are intentionally non-additive under multi-membership.
+- A missing orchestrator result remains unavailable, never a negative class.
+- Product-scoped supplier/customer transitions can coexist across intervals.
+
+## References
+
+International Organization for Standardization. (2017). *ISO 16355-4:2017:
+Applications of statistical and related methods to new technology and product
+development process—Part 4: Analysis of non-quantitative and quantitative Voice
+of Customer and Voice of Stakeholder*. https://www.iso.org/standard/62607.html
+
+Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*.
+World Wide Web Consortium. https://www.w3.org/TR/prov-dm/
diff --git a/docs/adr/0245-io-occupational-taxonomy-in-the-published-ontology.md b/docs/adr/0245-io-occupational-taxonomy-in-the-published-ontology.md
index a8476f33d..9cb30b26f 100644
--- a/docs/adr/0245-io-occupational-taxonomy-in-the-published-ontology.md
+++ b/docs/adr/0245-io-occupational-taxonomy-in-the-published-ontology.md
@@ -3,10 +3,7 @@
**Status:** Accepted
**Date:** 2026-08-26
**Extends:** [ADR 0004](0004-knowledge-graph-ontology.md), [ADR 0145](0145-psychometric-channel-weight-estimation.md), [ADR 0207](0207-repository-case-ontology-namespace-canonical.md), [ADR 0232](0232-worker-function-taxonomy-in-the-published-ontology.md)
-<<<<<<< HEAD
-=======
-**Superseded in part by:** [ADR 0252](0252-complete-2018-soc-hierarchy.md), which expands the major-group-only scheme into the complete 2018 SOC hierarchy.
->>>>>>> origin/feat/onet-rating-occupation-filter
+**Superseded in part by:** [ADR 0273](0273-complete-2018-soc-hierarchy.md), which expands the major-group-only scheme into the complete 2018 SOC hierarchy.
## Context
diff --git a/docs/adr/0247-worker-cgroup-memory-evidence.md b/docs/adr/0247-worker-cgroup-memory-evidence.md
new file mode 100644
index 000000000..4982052f9
--- /dev/null
+++ b/docs/adr/0247-worker-cgroup-memory-evidence.md
@@ -0,0 +1,72 @@
+# ADR 0247: Worker cgroup memory evidence before capacity limits
+
+- Status: Accepted
+- Date: 2026-08-27
+
+## Context
+
+The canonical worker was once observed with exit code 137 and was later
+recreated healthy. Exit 137 establishes a `SIGKILL`, not its cause. Recreation
+also discards the prior container's Docker state and cgroup counters, so the
+new container's `OOMKilled=false` cannot disprove a historical OOM.
+
+The base Compose service has no worker-specific memory limit or reservation.
+Docker therefore exposes the Docker Desktop VM capacity, not an accepted
+worker capacity envelope. Setting `mem_limit` from the current idle footprint,
+an arbitrary percentage, or an undocumented headroom multiplier would turn an
+unrepresentative observation into a production failure boundary.
+
+## Decision
+
+`scripts/capture_worker_memory_evidence.py` is the canonical worker-memory
+measurement procedure. It captures two snapshots around an explicitly chosen
+representative workload window from the unchanged `lineageweave` worker:
+
+- Docker status, exit code, `OOMKilled`, restart count, and configured memory
+ limit/reservation;
+- cgroup v2 `memory.current`, `memory.peak`, `memory.max`, and the keyed local
+ event counters in `memory.events.local`.
+
+The procedure rejects a container replacement, unavailable cgroup v2
+evidence, decreasing counters, and non-positive windows. It classifies OOM as
+confirmed only when Docker records `OOMKilled` or the kernel's local
+`oom_kill` counter increases. Exit 137 without either signal remains
+`sigkill_unattributed`. `high`, `max`, or `oom` deltas establish memory
+pressure without inventing an OOM kill.
+
+The core `low`, `high`, `max`, `oom`, and `oom_kill` counters are required.
+`oom_group_kill` is recorded when the host exposes it, but its absence remains
+an explicit `null` delta because neither OOM confirmation nor pressure
+classification depends on that optional group counter.
+
+If the unchanged worker exits during the window, Compose discovery includes
+stopped containers and Docker inspection preserves its terminal state. The
+terminated cgroup is no longer readable, so ending current usage and event
+deltas remain `null`; the output retains only the peak captured before exit
+and labels that limited scope. Docker `OOMKilled` may still confirm OOM and an
+otherwise unattributed exit 137 remains distinguishable. Every other terminal
+state without ending cgroup evidence is rejected rather than classified.
+
+No observation emits a memory-limit proposal. `memory.peak` is a measured
+maximum for that cgroup lifetime, but neither Docker nor the kernel defines a
+universal safety margin that turns it into a safe hard limit. A future limit
+requires an accepted representative workload/capacity envelope and a separate
+decision that names the workload, concurrency, host capacity, observation
+window, zero-OOM acceptance, and rollback procedure. Disabling the OOM killer
+is prohibited.
+
+## Consequences
+
+- Operators must capture evidence before recreating a failed worker.
+- A healthy idle sample proves only that the sampled window had no new local
+ pressure events; it is not capacity acceptance.
+- Canonical Compose remains unchanged until representative workload evidence
+ supports a bounded configuration.
+
+## References
+
+Docker, Inc. (2026a). *Define services in Docker Compose*. https://docs.docker.com/reference/compose-file/services/
+
+Docker, Inc. (2026b). *Resource constraints*. https://docs.docker.com/engine/containers/resource_constraints/
+
+The Linux Kernel Organization. (2026). *Control group v2*. https://docs.kernel.org/admin-guide/cgroup-v2.html
diff --git a/docs/adr/0251-fja-iopsy-cognitive-affective-behavioral-ontology.md b/docs/adr/0251-fja-iopsy-cognitive-affective-behavioral-ontology.md
index 1995686d4..8e361cdb0 100644
--- a/docs/adr/0251-fja-iopsy-cognitive-affective-behavioral-ontology.md
+++ b/docs/adr/0251-fja-iopsy-cognitive-affective-behavioral-ontology.md
@@ -1,7 +1,7 @@
# ADR 0251: I/O Psychology Cognitive, Affective, and Behavioral Ontology and Semantic Layer
-**Status:** Accepted
-**Date:** 2026-08-27
+**Status:** Accepted
+**Date:** 2026-08-27
**Deciders:** LineageWeave Architecture, ContextualWisdomLab Core
---
diff --git a/docs/adr/0256-evidence-bearing-voice-combinations.md b/docs/adr/0256-evidence-bearing-voice-combinations.md
index 79279130d..3be2bf845 100644
--- a/docs/adr/0256-evidence-bearing-voice-combinations.md
+++ b/docs/adr/0256-evidence-bearing-voice-combinations.md
@@ -53,7 +53,11 @@ compound lookup codes.
a voice.
- The public ontology represents each row as a qualified `VoiceAssignment`
linked from its post. Each assignment names one atomic SKOS voice concept;
- additional assignments retain evidence through `prov:wasDerivedFrom`.
+ closed-world SHACL admits only the twelve canonical concepts in
+ `postTypeScheme`, not an arbitrary SKOS concept. Additional assignments
+ retain the same authorized evidence Post through both the explicit Voice
+ evidence property and `prov:wasDerivedFrom`; SHACL requires those source
+ links to agree.
- Authorized post list/detail responses expose ordered voice assignments with
labels, truth state, and evidence availability but never internal assertion
identifiers. Filters match any associated voice, and repeated post cards show
diff --git a/docs/adr/0257-onet-occupation-rating-observation-store.md b/docs/adr/0257-onet-occupation-rating-observation-store.md
index 8e00e13a8..c00f9fe90 100644
--- a/docs/adr/0257-onet-occupation-rating-observation-store.md
+++ b/docs/adr/0257-onet-occupation-rating-observation-store.md
@@ -1,7 +1,7 @@
# ADR 0257: O*NET occupation-rating observation store
-**Status:** Accepted
-**Date:** 2026-08-27
+**Status:** Accepted
+**Date:** 2026-08-27
**Extends:** ADR 0166, ADR 0255, ADR 0256
## Context
diff --git a/docs/adr/0263-authorized-job-architecture-import.md b/docs/adr/0263-authorized-job-architecture-import.md
index 9802ed1b4..4b3edb463 100644
--- a/docs/adr/0263-authorized-job-architecture-import.md
+++ b/docs/adr/0263-authorized-job-architecture-import.md
@@ -1,7 +1,7 @@
# ADR 0263: Authorized job-family and job-series snapshot import
-**Status:** Accepted
-**Date:** 2026-08-27
+**Status:** Accepted
+**Date:** 2026-08-27
**Extends:** ADR 0001, ADR 0065, ADR 0248, ADR 0252
## Context
diff --git a/docs/adr/0272-twenty-millisecond-read-slo.md b/docs/adr/0272-twenty-millisecond-read-slo.md
new file mode 100644
index 000000000..6e7f219f3
--- /dev/null
+++ b/docs/adr/0272-twenty-millisecond-read-slo.md
@@ -0,0 +1,169 @@
+# ADR 0272: Twenty-millisecond read SLO
+
+- Status: Accepted
+- Date: 2026-08-31
+- Supersedes: the no-read-threshold statements in ADR 0206
+
+## Context
+
+Authenticated Dashboard reads against 43,189 source records exposed a planner
+cardinality error: PostgreSQL estimated one eligible row, repeatedly probed an
+index, and left the browser in a loading state. A timeout would only hide that
+cost. The product owner has now set an explicit requirement that every lookup
+complete within 20 milliseconds.
+
+ISO/IEC 25010:2023 makes performance efficiency part of the product quality
+model, while ISO/IEC 25023:2016 defines quantitative product-quality
+measurement. The threshold itself is the product-owner requirement; it is not
+derived from either standard or from a rule of thumb.
+
+## Decision
+
+1. Every authenticated REST `GET` and MCP read tool has a maximum 20 ms
+ service-processing budget. Measurement starts at application request entry
+ and ends when the complete response bytes are ready. It includes identity
+ and authorization checks, database work, projection, and serialization.
+2. Provider and measurement computations are asynchronous commands, not
+ lookups. Their enqueue, status, result, and citation reads remain subject to
+ 20 ms; the external computation duration is reported separately.
+3. Acceptance measures cold and warm reads. A cache-hit run alone is not
+ evidence. The declared deployment, dataset cardinality, response-byte
+ count, concurrency, hardware, and raw maximum distribution stay with the
+ runtime evidence. Every observed request must meet 20 ms; an average or
+ percentile cannot conceal a slower request.
+4. Setting a 20 ms timeout, returning an incomplete response, dropping
+ authorized evidence, or moving an ordinary read behind a job does not meet
+ the SLO. A failed request is a failed functional and performance check.
+5. Read paths use bounded projections and continuation where the complete
+ detail set cannot meet the budget. Summary counts remain exact over the
+ authorized population; continuation changes transport size, not evidence
+ membership, ranking, or measurement.
+ The Dashboard therefore returns at most 20 evidence-rich cases by default
+ (caller-bounded to 50), ordered by event instant, Post id, and case kind,
+ plus `next_case_cursor`. Its all-post, per-kind Event/Post, and lifecycle
+ counts are computed over the complete authorized period, never the page.
+6. PostgreSQL plans must use narrow, maintained access paths instead of
+ rescanning wide source bodies. Eligibility predicates remain logically
+ identical, ABAC executes before aggregation, and source/provenance tables
+ remain authoritative and normalized. Dashboard authorization, period,
+ active-source, source-context, case-analysis, and ingestion-failure fields
+ are maintained one row per Post in `dashboard_post_read_projection` by the
+ same transaction that changes their authoritative source rows. Migration
+ replay rebuilds the complete projection with set-based `EXISTS` checks;
+ it never derives a second business fact or admits eventual counts.
+ Exact case Event/Post and lifecycle totals use the companion
+ `dashboard_case_rollup_read_projection`. It records every contributing
+ evidence Post id, so the read rejects a rollup when any source falls outside
+ the caller's current scope instead of leaking a pre-aggregated count.
+ Exact Post totals use `dashboard_post_daily_summary`, keyed by natural
+ event day and the complete visibility/entity/PU/context scope. The source
+ projection trigger applies old/new row deltas in the same transaction;
+ migration replay rebuilds the summary set-wise before deltas resume.
+ Dashboard metrics, complete case rollups, bounded case/detail rows, and
+ persisted topic readiness/detail are returned by one JSON statement.
+7. k6 and database-plan checks enforce the same 20 ms maximum. The gate records
+ cold and warm observations separately and fails on any HTTP, authorization,
+ schema, citation, or latency failure.
+8. Backend PostgreSQL sessions disable JIT. Runtime plans showed compilation
+ startup dominating the bounded interactive aggregates without changing the
+ result; analytical workers may opt in only with their own measured plan.
+9. Voice taxonomy reads use trigger-maintained per-post truth projections and
+ natural day/month rollups. Writes apply atomic old/new deltas; they never
+ recount a shared group. Stored assertion validity instants wake the durable
+ worker through PostgreSQL notification, so a time transition is reconciled
+ at its recorded instant without an invented polling interval. Pool startup
+ prepares the three bounded query shapes before accepting HTTP traffic.
+10. The existing ten-connection application pool is established eagerly, and
+ every connection loads the UUID-array and text-array codecs during
+ initialization. Account scope and permissions use those types on every
+ authenticated read; connection creation and codec discovery are startup
+ work and may not consume the lookup budget.
+ The Dashboard statement exceeds asyncpg's default cacheable-query byte
+ ceiling, so every connection admits it to the existing statement cache,
+ uses a generic PostgreSQL plan, and executes each query shape once during
+ pool initialization. Pool reset restores that measured plan policy.
+11. A Post detail lookup returns the complete metadata and evidence envelope
+ without materializing `source_post.post_body`. The separately authorized
+ `/api/posts/{post_id}/body` response streams the unchanged source text;
+ its time to first byte remains within 20 ms, while complete transfer time
+ and bytes per second are recorded as payload-throughput evidence. This is
+ the sole exception to the complete-response boundary in item 1: a measured
+ 1,898,576-byte source body required more than 20 ms merely to leave the
+ process, so pretending that full delivery met the lookup budget would make
+ the SLO physically false. The UI renders the metadata immediately, aborts
+ an obsolete body stream when navigation changes, and offers an explicit
+ retry after a transfer failure. It never truncates or substitutes the
+ source text.
+12. The body stream reads bounded PostgreSQL TOAST slices rather than first
+ materializing the whole value. Runtime comparison on the same exact body
+ selected 262,144-character slices: 65,536-character slices delivered a
+ 6.2--17.8 ms TTFB but required 321--396 ms total; 262,144-character slices
+ retained a 16.0--19.0 ms TTFB and reduced total transfer to 143--167 ms.
+ The slice size is therefore a recorded measured selection, not an
+ untested rule of thumb. Any later change must repeat the same byte-exact
+ comparison and preserve the 20 ms maximum TTFB.
+13. The unfiltered Post page uses a transaction-local custom PostgreSQL plan.
+ The shared generic plan could not prune its nullable search and filter
+ branches and measured 42--64 ms for the database fetch. `EXPLAIN
+ (ANALYZE, BUFFERS)` on the identical bound statement measured 2.207 ms
+ planning and 4.363 ms execution with a custom plan; the complete direct
+ route measured 7.423 ms on its first observation and 2.736--3.094 ms after
+ it. `SET LOCAL` confines this exception to the default-page transaction,
+ and the pool remains `force_generic_plan` afterward. Search and filtered
+ reads retain the shared generic-plan policy until separately measured
+ evidence supports a different exact query shape.
+14. Visibility and a single selected Voice obtain their exact authorized total
+ from the transaction-maintained Voice rollup rather than `count(*) over()`
+ on all eligible Posts. Before that change, both generic and custom plans
+ measured 45--51 ms; focused direct observations afterward measured
+ 2.87--3.66 ms for visibility and 6.91--16.28 ms for one Voice. Multiple
+ Voice selections may overlap, so they retain an exact distinct count until
+ a maintained intersection projection exists; summing category counts would
+ double-count multi-membership Posts.
+15. Historical revision bodies do not add generated stored length columns to
+ `source_post_revision`. A diagnostic attempt caused a multi-gigabyte table
+ rewrite and lock, so it was cancelled without treating the rewrite as a
+ read optimization. Historical bodies instead stream exact xmin-stable
+ chunks to EOF without a fabricated Content-Length. Current bodies use the
+ separately maintained Post-list projection populated on source writes.
+16. Post search reads normalized body, source, ontology/evidence, and master
+ text from the transaction-maintained `post_list_read_projection`. Unit
+ separator boundaries prevent a phrase from matching across two unrelated
+ source fields, while the source and normalized evidence tables remain
+ authoritative. Body normalization and full-text vectors are computed on
+ writes and one guarded historical backfill, not by decompressing wide
+ source bodies during a lookup. Project, role, person, summary, event,
+ customer, process-unit, author, and affiliation changes refresh the same
+ Post row in their write transaction. Search no longer uses the former
+ 0.45 word-similarity guesses; exact evidence and the separately governed
+ normalized-identifier recovery path remain searchable.
+ Projection triggers fire only for authoritative columns that contribute to
+ that search text. In particular, a member locale preference write does not
+ rebuild every Post authored by that member.
+17. Evidence-rich JSON responses use HTTP gzip content negotiation through the
+ framework middleware. This changes transport encoding, not the authorized
+ evidence or response schema. Before activation, the exact Dashboard response
+ measured 96,415 bytes uncompressed and 10,364 bytes through gzip; the
+ uncompressed payload therefore spent network bandwidth unrelated to query
+ correctness. Clients without gzip support continue to receive the same
+ uncompressed bytes, and acceptance still measures the complete negotiated
+ response against the unchanged 20 ms maximum.
+
+## Consequences
+
+The existing Dashboard observation that defined no latency threshold is no
+longer sufficient. Each read surface needs exact-head runtime evidence before
+release. Slow endpoints stay an explicit product gap until both cold and warm
+checks meet the budget; documentation or a green unit suite cannot close it.
+
+## References
+
+International Organization for Standardization. (2016). *Systems and software
+engineering—Systems and software Quality Requirements and Evaluation
+(SQuaRE)—Measurement of system and software product quality* (ISO/IEC Standard
+No. 25023:2016). https://www.iso.org/standard/35747.html
+
+International Organization for Standardization. (2023). *Systems and software
+engineering—Systems and software Quality Requirements and Evaluation
+(SQuaRE)—Product quality model* (ISO/IEC Standard No. 25010:2023).
+https://www.iso.org/standard/78176.html
diff --git a/docs/adr/0273-complete-2018-soc-hierarchy.md b/docs/adr/0273-complete-2018-soc-hierarchy.md
new file mode 100644
index 000000000..8392c3cf3
--- /dev/null
+++ b/docs/adr/0273-complete-2018-soc-hierarchy.md
@@ -0,0 +1,50 @@
+# ADR 0273: Complete 2018 SOC hierarchy as a generated ontology fragment
+
+**Status:** Accepted
+**Date:** 2026-08-27
+
+## Context
+
+ADR 0245 publishes only the 23 SOC major groups. That is insufficient for
+occupation-level evidence: the official 2018 SOC contains four aggregation
+levels and 1,447 classifications. A label-derived parent or a locally invented
+job-family crosswalk would violate the repository's evidence boundary.
+
+## Decision
+
+1. Import the complete official 2018 SOC structure: 23 major groups, 98 minor
+ groups, 459 broad occupations, and 867 detailed occupations.
+2. Preserve the source row's level and parent exactly. Publish `skos:broader`
+ only from that parent column; never derive hierarchy from code digits or
+ titles.
+3. Keep the normalized source snapshot at
+ `docs/ontology/data/soc-2018-structure.csv` and generate
+ `docs/ontology/soc-2018-structure.ttl` deterministically. The source XLSX
+ SHA-256 is
+ `ade08af40923266f3a854842e888ca3e93c15b26a147c20a2b12a61f4c4f4077`;
+ the normalized CSV SHA-256 is
+ `7de1c9d4da14d8eeb95197974d9dc1989752ebda235dd234b1693f336891f68e`.
+4. Treat SOC as a statistical occupational classification, not an employer's
+ job family, job series, position, person trait, or psychometric score. Those
+ bindings require separately authorized source assertions.
+5. Runtime and publication loaders merge the governed Turtle fragments into
+ one graph. The public artifact remains one canonical ontology namespace and
+ is serialized from that merged graph, rather than concatenating independent
+ Turtle documents. Its manifest identifies and hashes every governed input.
+
+## Consequences
+
+Occupation evidence can address every official 2018 SOC level without an
+invented mapping. The generated fragment is larger, but review remains bounded
+by the pinned source digests, deterministic renderer, exact counts, parent
+closure, and graph tests.
+
+## References
+
+U.S. Bureau of Labor Statistics. (2018). *2018 Standard Occupational
+Classification system*. U.S. Department of Labor.
+https://www.bls.gov/soc/2018/
+
+U.S. Bureau of Labor Statistics. (2018). *Standard Occupational
+Classification and coding structure, 2018 SOC*. U.S. Department of Labor.
+https://www.bls.gov/soc/2018/soc_2018_class_and_coding_structure.pdf
diff --git a/docs/adr/0274-post-scoped-source-reference-research.md b/docs/adr/0274-post-scoped-source-reference-research.md
new file mode 100644
index 000000000..afc8d4198
--- /dev/null
+++ b/docs/adr/0274-post-scoped-source-reference-research.md
@@ -0,0 +1,98 @@
+# ADR 0274: Post-scoped source-reference research
+
+**Status:** Accepted
+**Date:** 2026-08-26
+
+## Context
+
+Issue #611 decomposes closed PR #490. The remaining ADR 0133 criterion is
+absent from protected `main`: a post-scoped lead from a source semantic unit or
+image region, public search, retrieval of a cited public page, orchestrator
+judgment, and a persisted research citation.
+
+ADR 0005 verifies an already extracted ontology relation with a presence or
+absence search signal. ADR 0215 verifies Global Ask public claims from SearXNG
+snippets and **never fetches result URLs**. Those contracts stay unchanged.
+Source-reference research needs the retrieved page itself because the reader
+next action is to open the cited public resource and compare it with this
+post's source unit or image region.
+
+Private source content, people facts, TEPP artifacts, and fast-mlsirm artifacts
+must not leave the authorization boundary. EgressWeave is an exact-host
+allowlist and cannot retrieve arbitrary public pages. Retrieval therefore needs
+its own public-target SSRF and redirect rejection.
+
+## Decision
+
+1. Only a source post whose persisted `visibility_code` is `public` may send
+ lead text to SearXNG or retrieve a result URL. Private posts fail closed
+ without egress.
+2. Leads are existing `post_content_unit` rows (non-image kinds with non-empty
+ `unit_text`) or `post_content_image_region` rows with caption or extracted
+ text. The workflow does not invent a unit, region, claim, or score.
+3. SearXNG search reuses the self-hosted `SEARXNG_BASE_URL` boundary already
+ used by ADR 0005 and ADR 0215. The deployment must explicitly provide
+ positive `SOURCE_RESEARCH_MAXIMUM_LEADS` and
+ `SOURCE_RESEARCH_MAXIMUM_RESULTS` resource budgets. No undocumented default
+ or evidence-free ranking threshold is inferred; without both budgets the
+ channel is unavailable.
+4. Result retrieval is a distinct public-target client: HTTP(S) only, no
+ userinfo, no localhost or `.local` hosts, no non-global resolved addresses
+ including IPv4-mapped forms, no search-engine hosts, redirects refused, and
+ a bounded response body. DNS is resolved before connect; the client connects
+ to a previously classified public address and sends the original Host header.
+5. The retrieved excerpt crosses contextual-orchestrator with `mode="verify"`
+ and `reasoning_effort="auto"`. Allowed judgments are
+ `research_supported`, `research_refuted`,
+ `research_not_enough_information`, and `research_unavailable`. Supported or
+ refuted without a cited URL downgrades to not enough information.
+6. Citations persist in 3NF `source_research_citation`. External URLs stay
+ distinct from internal post identifiers. The workflow never mutates
+ ontology, Knowledge Graph, Event Lineage, TEPP, or fast-mlsirm state.
+7. Missing SearXNG, orchestrator, public target, or retrieved text is an
+ explicit unavailable outcome, never a fabricated negative judgment.
+8. A transient unavailable re-check is returned for the current attempt but
+ does not erase a lead's last determinate persisted judgment or cited public
+ resource. Citation reads use the persisted source-unit and image-region
+ order as the deterministic tie-break within one transaction timestamp.
+9. The bounded lead sequence alternates the two persisted source-kind streams,
+ beginning with whichever kind occurs first in document order. This gives
+ both a semantic-unit stream and an image-region stream a place whenever the
+ supplied budget can contain both, without an inferred score, weight, or
+ content-ranking heuristic. Each stream retains its persisted source order.
+10. A settled Global Ask answer may attach only the determinate persisted
+ references belonging to its already-authorized cited posts. Delivery
+ rechecks current publication eligibility, limits historical answers to
+ references checked by the requested cutoff, and returns the same reference
+ fields through REST, UI, report, and MCP's shared durable answer. Missing
+ references remain absent; no title or URL is synthesized.
+
+## Consequences
+
+- Readers can research a public post's own source unit or image region without
+ mixing Global Ask snippet verification into the same table.
+- A reader can move from an Ask citation to its event card, internal post, and
+ persisted related public document without treating that document as Event
+ Lineage or ontology state.
+- Private posts remain inside the authorization boundary.
+- Redirect-based SSRF and DNS rebinding are rejected at the retrieval client,
+ not compensated later in UI copy.
+
+## Related
+
+Implements the remaining ADR 0133 delivery named in issue #611 on current
+`main`. Distinct from [ADR 0005](0005-relation-verification-agent.md) and
+[ADR 0215](0215-global-ask-public-claim-verification.md).
+
+## References
+
+Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV
+ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/
+
+SearXNG. (2026). *Search API*. https://docs.searxng.org/dev/search_api.html
+
+Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). FEVER: A
+large-scale dataset for fact extraction and verification. In *Proceedings of
+the 2018 Conference of the North American Chapter of the Association for
+Computational Linguistics: Human Language Technologies* (Vol. 1, pp. 809–819).
+Association for Computational Linguistics. https://doi.org/10.18653/v1/N18-1074
diff --git a/docs/adr/0275-persisted-public-claim-admission.md b/docs/adr/0275-persisted-public-claim-admission.md
new file mode 100644
index 000000000..9da151d0f
--- /dev/null
+++ b/docs/adr/0275-persisted-public-claim-admission.md
@@ -0,0 +1,55 @@
+# ADR 0275: Persist public-claim admission before external verification
+
+## Status
+
+Accepted
+
+## Context
+
+ADR 0215 defines opt-in public verification and keeps external evidence
+separate from internal authority. Its first implementation nominated semantic
+facts by token overlap with the question. Token overlap is neither provenance
+nor a governed claim-admission decision, and it can change when wording changes.
+
+The abandoned draft PR #679 proposed replacing that implementation wholesale.
+The current Global Ask queue, cutoff behavior, authorization scope, SearXNG
+validation, and contextual-orchestrator verifier have since evolved and remain
+authoritative. Only the persisted admission boundary is still missing.
+
+## Decision
+
+`public_claim_envelope` stores one bounded claim kind, exact claim text, source
+post, PROV-O `prov:wasDerivedFrom` assertion, and egress decision. The evidence
+resource must bind to that same source post. Only organization presence, public
+event, and public relationship kinds are admitted; person, Keyman, measurement,
+prompt, and source-body payloads have no storage code.
+
+Production Global Ask loads at most four envelopes whose source post is both
+public and cited in the completed answer. The per-question opt-in remains the
+durable consent boundary. A cutoff excludes envelopes or source posts created
+after that cutoff. Changing a post from public revokes egress eligibility.
+
+The persisted envelope supplies candidates to the existing ADR 0215 verifier.
+It does not replace SearXNG URL validation, contextual-orchestrator adjudication,
+or the distinction between external URLs and internal post citations. No claim
+is inferred from question-token overlap in the production path. When no current,
+authorized envelope exists, verification reports no public claims and performs
+no external request.
+
+## Consequences
+
+- Public egress admission is stable, reviewable, and provenance-bearing.
+- Existing verification transport and outcome contracts remain unchanged.
+- A producer must persist a governed envelope before a claim becomes eligible;
+ absence stays unavailable rather than being repaired heuristically.
+- Draft PR #679 remains historical evidence for the missing boundary and is not
+ merged wholesale over the current semantic stack.
+
+## References
+
+Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV
+ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/
+
+Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). FEVER: A
+large-scale dataset for fact extraction and verification. In *Proceedings of
+NAACL-HLT 2018* (pp. 809–819). https://doi.org/10.18653/v1/N18-1074
diff --git a/docs/adr/0276-digest-bound-project-journey-temporal-evidence.md b/docs/adr/0276-digest-bound-project-journey-temporal-evidence.md
new file mode 100644
index 000000000..d0a71b060
--- /dev/null
+++ b/docs/adr/0276-digest-bound-project-journey-temporal-evidence.md
@@ -0,0 +1,61 @@
+# ADR 0276: Digest-bound project-journey temporal evidence
+
+- Status: Accepted on this stacked branch; not protected-main truth until merge
+- Date: 2026-08-28
+- Depends on: ADR 0132, ADR 0231, ADR 0243; TEPP PR #291
+- Figma file ID: `SBpgot7uTvMxEaxUwvoc0S`
+
+## Context
+
+TEPP PR #291 publishes canonical JSON and GraphML for bounded Allen interval-
+consistency results. The artifact binds a run, snapshot, exact input digest,
+ordered event pair, observed/derived status, and supporting assertion ordinals.
+It deliberately does not claim that temporal order is a causal transition,
+project predecessor, or business-process branch.
+
+LineageWeave already admits related predecessor paths through authorized
+`post_lineage_edge` evidence. Promoting every temporally ordered pair to a
+project journey would contradict PRD-FR-5E and ADR 0243.
+
+## Decision
+
+LineageWeave accepts only canonical artifact bytes whose SHA-256, run,
+snapshot, and exact input digest match caller-computed expected values. The
+remote run must also match a persisted terminal TEPP result. Metadata,
+relations, elementary Allen kinds, and support ordinals persist in normalized
+tables.
+
+Every admitted temporal pair must already be an exact `post_lineage_edge`.
+The database foreign key enforces that boundary. Temporal evidence may
+corroborate the time order of an existing related-history path; it never
+creates a predecessor, branch, responsibility handoff, or causal transition.
+A branch is visible only when the independently admitted lineage graph already
+contains that topology. A transition still requires its separately governed
+observed business or responsibility evidence.
+
+The Project History API attaches the newest immutable temporal evidence whose
+analysis cutoff does not exceed the requested view cutoff to the corresponding
+visible edge after ABAC selects both endpoints. The customer UI says what the user can do next—open the supporting
+records and compare dates—and never names the calculation module.
+
+GraphML is an equivalent provider export, not the ingestion authority. The
+canonical typed JSON is the sole admitted payload so two representations
+cannot diverge inside the database.
+
+## Consequences
+
+- Exact temporal consistency becomes durable and auditable without duplicating
+ mathematical reasoning in Python.
+- A valid artifact containing a pair absent from Event Lineage fails closed at
+ the foreign-key boundary and rolls back its transaction.
+- A future contract that explicitly carries business predecessor or transition
+ semantics requires a new ADR; this artifact cannot be reinterpreted later.
+
+## References
+
+Allen, J. F. (1983). Maintaining knowledge about temporal intervals.
+*Communications of the ACM, 26*(11), 832–843.
+https://doi.org/10.1145/182.358434
+
+World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*.
+https://www.w3.org/TR/prov-o/
diff --git a/docs/adr/0277-customer-master-bounded-read.md b/docs/adr/0277-customer-master-bounded-read.md
new file mode 100644
index 000000000..61b077c5e
--- /dev/null
+++ b/docs/adr/0277-customer-master-bounded-read.md
@@ -0,0 +1,40 @@
+# ADR 0277: Bounded Customer Master read
+
+- Status: Accepted
+- Date: 2026-08-31
+
+## Context
+
+Customer Master returned as many as one hundred customer groups and one
+hundred author groups, each carrying as many as twenty related Posts. The
+browser rendered only the first thirty groups but still downloaded the whole
+response. With 43,189 Posts the response was 274,761 bytes and required
+1.4--1.7 seconds.
+
+## Decision
+
+Customer and author groups are trigger-maintained in narrow read projections
+using the same source eligibility and ABAC dimensions as the authoritative
+Posts. Counts are adjusted in the source write transaction; no cached count
+may outlive the fact that produced it. Reads return twenty groups by default,
+at most fifty, with independent count-ordered keyset cursors for customer and
+author groups. The payload includes exact authorized totals over all groups.
+
+The group summary does not carry related Posts. Opening a group starts an
+independent bounded related-Post read, ordered by `(created_at, post_id)`, and
+exposes a group-bound continuation. It is never silently truncated or
+replaced by an approximate sample. The UI requests the first page only when
+the reader opens the group and requests continuation only when more evidence
+exists.
+
+Readiness follows initialization of every pool connection's projection paths
+and the issuer-bound JWKS cache. A signing-key miss still performs the
+existing one-time forced refresh, so startup warming does not pin a rotated
+key or add an arbitrary cache lifetime.
+
+## Consequences
+
+Response size and serialization are bounded without changing membership,
+counts, provenance, or authorization. Existing source identifiers remain
+hints rather than customer bindings. Runtime acceptance measures cold and
+warm complete-response latency against ADR 0272.
diff --git a/docs/adr/0278-selected-topic-context-rankings.md b/docs/adr/0278-selected-topic-context-rankings.md
new file mode 100644
index 000000000..8d6343bd5
--- /dev/null
+++ b/docs/adr/0278-selected-topic-context-rankings.md
@@ -0,0 +1,26 @@
+# ADR 0278: Selected topic-context Rankings
+
+- Status: Accepted
+- Date: 2026-08-31
+
+## Decision
+
+Rankings has no keyword or default lexical channel. An unselected read returns
+only ABAC-visible persisted context choices. A ranking requires the exact
+`topic_model_run_id`, `topic_influence_run_id`, `topic_index`,
+`dimension_code`, and `context_id`; no value is maximized, pooled, copied, or
+renormalized across contexts. The primary channel orders accepted persisted
+`topic_post_context_influence.influence_value` evidence for that selection.
+Newest-first may participate only over the identical selected membership
+population. RankWeave owns exact Cormack fusion and its stopping certificate.
+
+Migration 0268 adds maintained PostgreSQL access paths over the normalized
+ADR 0210 tables. ABAC remains a source-post eligibility join before any row is
+returned. The indexes contain no new fact and require no ontology term.
+
+## Consequences
+
+The endpoint cannot fabricate a ranking before the buyer selects one governed
+topic and context. Missing producer evidence remains unavailable with an
+actionable next step. ADR 0024's synthetic fixed-query lexical ranking is
+retired; ADR 0167 contribution disclosure remains required.
diff --git a/docs/adr/0279-consumer-selective-durable-worker-ownership.md b/docs/adr/0279-consumer-selective-durable-worker-ownership.md
new file mode 100644
index 000000000..c8b2f2a27
--- /dev/null
+++ b/docs/adr/0279-consumer-selective-durable-worker-ownership.md
@@ -0,0 +1,87 @@
+# ADR 0279: Select durable worker ownership by consumer
+
+- Status: Accepted
+- Date: 2026-09-01
+- Related: [0098](0098-valkey-backed-post-content-ingestion.md),
+ [0204](0204-analysis-run-short-transaction-delivery.md),
+ [0218](0218-current-contract-mcp-global-ask.md), and
+ [0224](0224-canonical-compose-project.md)
+
+This decision supersedes only ADR 0224's single-process ownership of all
+durable consumers; its canonical-project and API/worker separation decisions
+remain in force.
+
+## Context
+
+The canonical worker owns every durable consumer under one process-wide
+PostgreSQL advisory lease. That is safe against duplicate stream cursors, but
+it couples unrelated availability. An operator who intentionally stops the
+long-running post-content backfill also stops Global Ask, leaving accepted Ask
+jobs queued even though Ask does not depend on post-content consumption.
+
+Starting a second copy of the broad worker is unsafe: it duplicates every
+consumer and conflicts with ADR 0098's single cursor owner. Moving Ask into the
+HTTP process would reverse ADR 0218 and ADR 0224's durable asynchronous
+boundary. A guessed lease duration or polling ratio is unnecessary because
+PostgreSQL session advisory locks already provide crash cleanup.
+
+## Decision
+
+1. The worker accepts an explicit comma-separated
+ `LINEAGEWEAVE_WORKER_CONSUMERS` set from the closed vocabulary
+ `analysis_run`, `post_content`, `global_ask`, `voice_taxonomy`, and
+ `topic_influence`. Missing or blank configuration preserves the historical
+ behavior and starts every configured consumer. An unknown name fails
+ startup; it is never ignored. If an explicit selection contains only an
+ optional consumer whose transport is unavailable or invalid, startup fails
+ before the heartbeat begins instead of reporting a healthy no-op worker.
+2. Each active consumer has a distinct PostgreSQL session advisory-lock name.
+ One worker process acquires all of its selected locks on one held session
+ before starting any consumer. Failure to acquire any lock releases the
+ already-acquired locks and fails the process before queue reads. PostgreSQL
+ session cleanup remains the crash-release mechanism; no timeout or
+ heartbeat lease is introduced.
+3. The canonical Compose project runs two non-overlapping worker services.
+ `backend-worker` owns analysis-run, post-content, Voice transition, and the
+ configured topic-influence consumer. `backend-ask-worker` owns only Global
+ Ask. Both use the same worker image, progress health contract, PostgreSQL,
+ Valkey, and contextual-orchestrator boundary. The backend depends on and
+ health-gates only `backend-ask-worker`; a targeted `docker compose up
+ backend` therefore never starts post-content. A full `docker compose up`
+ still starts the independent broad worker as a top-level canonical service.
+4. Stopping `backend-worker` therefore cannot start, retry, recover, or consume
+ post-content work through the Ask service. Global Ask remains durable and
+ asynchronous through its own existing ledger and wake-up consumer.
+5. Exact-revision runtime acceptance verifies both worker images. This
+ decision does not authorize starting either worker against an existing
+ queue, changing retry timing, increasing concurrency, or requeuing failed
+ work.
+6. The legacy process-wide lease and the new per-consumer leases are different
+ lock identities. Deployment must therefore stop the legacy worker before
+ starting either selected worker; a rolling overlap is prohibited. The
+ canonical Compose upgrade procedure performs that stop first and verifies
+ the old container is absent before either replacement starts.
+
+## Considered alternatives
+
+- Keep one broad worker: rejected because unrelated post-content operations
+ continue to suspend Global Ask.
+- Start a second broad worker: rejected because duplicate stream owners race
+ cursor advancement and violate ADR 0098.
+- Run Ask in the API process: rejected because API restarts would again own
+ durable work and blur the ADR 0218 asynchronous boundary.
+
+## Consequences
+
+- Ask can progress while post-content consumption is intentionally stopped.
+- Consumer overlap fails closed even when deployment configuration is wrong.
+- The canonical project has one additional worker container and health gate.
+- Each worker process holds one pooled PostgreSQL connection for its selected
+ advisory locks for its lifetime, matching the previous single-worker lease
+ cost per process.
+
+## References
+
+PostgreSQL Global Development Group. (2026). *PostgreSQL 18.6 documentation:
+9.28 system administration functions*.
+https://www.postgresql.org/docs/18/functions-admin.html
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 8979111c1..41e5a608a 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -1,5 +1,8 @@
# Architecture Decision Records
+- [ADR 0278: Selected topic-context Rankings](0278-selected-topic-context-rankings.md)
+- [ADR 0279: Select durable worker ownership by consumer](0279-consumer-selective-durable-worker-ownership.md)
+
ADRs are the normative source for architecture decisions. Research notes,
implementation matrices, schema references, runtime evidence, and Storybook
inventories remain supporting documents unless an ADR explicitly promotes a
@@ -21,19 +24,22 @@ decision from them.
| [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md), [0222](0222-project-nodes-in-ontology-neighborhood.md), [0256](0256-evidence-bearing-voice-combinations.md) |
| [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0213](0213-global-ask-embedding-pool-release.md) |
| [`GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md`](../doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md) | [0215](0215-global-ask-public-claim-verification.md) |
+| Persisted public-claim admission | [0275](0275-persisted-public-claim-admission.md) |
| [`GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md`](../doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md) | [0216](0216-global-ask-knowledge-cutoff.md) |
| [`GLOBAL_ASK_QUERY_REWRITE_REFERENCES.md`](../doctoring/GLOBAL_ASK_QUERY_REWRITE_REFERENCES.md) | [0217](0217-evidence-constrained-semantic-query-rewrite.md) |
| [`MCP_GLOBAL_ASK_REFERENCES.md`](../doctoring/MCP_GLOBAL_ASK_REFERENCES.md) | [0218](0218-current-contract-mcp-global-ask.md) |
| [`operability/http-concurrency-evidence.md`](../operability/http-concurrency-evidence.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0212](0212-single-query-authorized-post-filter-options.md), [0213](0213-global-ask-embedding-pool-release.md) |
+| [`READ_PERFORMANCE_REFERENCES.md`](../doctoring/READ_PERFORMANCE_REFERENCES.md) | [0272](0272-twenty-millisecond-read-slo.md) |
| [`operability/mcp-concurrency-evidence.md`](../operability/mcp-concurrency-evidence.md) | [0218](0218-current-contract-mcp-global-ask.md) |
| Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) |
+| Project-journey temporal evidence | [0276](0276-digest-bound-project-journey-temporal-evidence.md) |
| [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) |
| [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) |
| [`WORKER_FUNCTION_TAXONOMY_REFERENCES.md`](../doctoring/WORKER_FUNCTION_TAXONOMY_REFERENCES.md) | [0232](0232-worker-function-taxonomy-in-the-published-ontology.md) |
| [`OCCUPATIONAL_CONSTRUCT_REFERENCES.md`](../doctoring/OCCUPATIONAL_CONSTRUCT_REFERENCES.md) | [0248](0248-occupational-construct-evidence-boundary.md), [0250](0250-official-occupational-construct-catalog-sync.md), [0253](0253-catalog-bound-occupational-construct-extraction.md), [0255](0255-occupational-construct-ontology-navigation.md), [0265](0265-occupational-construct-catalog-search.md) |
| [`IOPSY_TAXONOMY_REFERENCES.md`](../doctoring/IOPSY_TAXONOMY_REFERENCES.md) | [0251](0251-fja-iopsy-cognitive-affective-behavioral-ontology.md) |
-| [`IO_OCCUPATIONAL_TAXONOMY_REFERENCES.md`](../doctoring/IO_OCCUPATIONAL_TAXONOMY_REFERENCES.md) | [0245](0245-io-occupational-taxonomy-in-the-published-ontology.md) |
+| [`IO_OCCUPATIONAL_TAXONOMY_REFERENCES.md`](../doctoring/IO_OCCUPATIONAL_TAXONOMY_REFERENCES.md) | [0245](0245-io-occupational-taxonomy-in-the-published-ontology.md), [0273](0273-complete-2018-soc-hierarchy.md) |
| [`ANALYSIS_RUN_REGISTRY_REFERENCES.md`](../doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md) | [0013](0013-normalized-analysis-run-registry.md)-[0022](0022-authorized-tepp-start.md) registry family, [0017](0017-authorized-analysis-run-create.md), [0020](0020-analysis-run-retention-purge.md), [0021](0021-authorized-analysis-run-start.md) |
| [`DESIGN_TOKEN_REFERENCES.md`](../doctoring/DESIGN_TOKEN_REFERENCES.md) | [0099](0099-badge-and-accent-color-tokens.md), [0118](0118-uiux-standard-guide-v3-design-overhaul.md) |
diff --git a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
index c22317628..43e8846dc 100644
--- a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
+++ b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
@@ -10,7 +10,7 @@ Ask evidence dialog, and the Storybook inventory.
|---|---|---|
| W3C Design Tokens Format Module 2025.10 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--size-control-min`, `--radius-chip`, `--radius-control`, `--radius-panel`, and `--font-*`. `CitationChip`, `PopupCloseButton`, `CutoffKnownBody`, and `LineageEntityPicker` read those names through `App.css`. |
| Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. |
-| WCAG 2.2 | Give interactive controls programmatic names and announce an asynchronous evidence failure instead of leaving a perpetual loading state. | Component interaction tests exercise the named controls; `EvidencePanel` exposes its terminal failure with `role="alert"`. This is targeted evidence, not a claim of complete WCAG conformance. |
+| WCAG 2.2 | Give interactive controls programmatic names, meet SC 2.5.8's 24×24 CSS-pixel minimum target, and announce an asynchronous evidence failure instead of leaving a perpetual loading state. | Component interaction tests exercise the named controls; Dashboard evidence links consume `--size-control-min`; `EvidencePanel` exposes its terminal failure with `role="alert"`. This is targeted evidence, not a claim of complete WCAG conformance. |
| WAI-ARIA APG Dialog (Modal) Pattern | A surface marked `aria-modal="true"` must behave modally: focus moves inside, `Tab` and `Shift+Tab` remain inside, and `Escape` closes the layer. | `AskEvidenceLayerPopup` moves initial focus inside the dialog and explicitly cycles forward/backward keyboard focus between its actionable controls; component tests cover both focus-loop directions and Escape. Its evidence lists use dialog-specific accessible labels so assistive technology can distinguish the modal list from the still-rendered inline answer. |
## APA 7th references
diff --git a/docs/doctoring/READ_PERFORMANCE_REFERENCES.md b/docs/doctoring/READ_PERFORMANCE_REFERENCES.md
new file mode 100644
index 000000000..f4ffeaa9c
--- /dev/null
+++ b/docs/doctoring/READ_PERFORMANCE_REFERENCES.md
@@ -0,0 +1,19 @@
+# Read-performance references
+
+## APA 7th references
+
+International Organization for Standardization. (2016). *Systems and software
+engineering—Systems and software Quality Requirements and Evaluation
+(SQuaRE)—Measurement of system and software product quality* (ISO/IEC Standard
+No. 25023:2016). https://www.iso.org/standard/35747.html
+
+International Organization for Standardization. (2023). *Systems and software
+engineering—Systems and software Quality Requirements and Evaluation
+(SQuaRE)—Product quality model* (ISO/IEC Standard No. 25010:2023).
+https://www.iso.org/standard/78176.html
+
+## Adoption trace
+
+ADR 0272 uses ISO/IEC 25010's product-quality model and ISO/IEC 25023's
+quantitative measurement framing. The 20 ms maximum is the product owner's
+explicit acceptance requirement, not a value supplied by either standard.
diff --git a/docs/doctoring/WORKER_CGROUP_MEMORY_REFERENCES.md b/docs/doctoring/WORKER_CGROUP_MEMORY_REFERENCES.md
new file mode 100644
index 000000000..e9fe9c9d7
--- /dev/null
+++ b/docs/doctoring/WORKER_CGROUP_MEMORY_REFERENCES.md
@@ -0,0 +1,22 @@
+# Worker cgroup memory references
+
+This supporting register documents the evidence boundary adopted by ADR 0247.
+Docker Compose defines `mem_limit` as a hard allocation limit and
+`mem_reservation` as a reservation. Docker Engine documents that the kernel
+kills container processes on OOM by default and warns against disabling that
+behavior without a hard memory limit. Linux cgroup v2 defines `memory.peak` as
+the maximum observed usage and `memory.events.local` as the non-hierarchical
+counter source; `oom_kill` counts processes killed by an OOM killer.
+
+These contracts do not specify a universal multiplier or percentage for
+turning one observed peak into a safe service limit. LineageWeave therefore
+records measured evidence and leaves the limit unset until a representative
+capacity acceptance is approved.
+
+## References — APA 7th
+
+Docker, Inc. (2026a). *Define services in Docker Compose*. https://docs.docker.com/reference/compose-file/services/
+
+Docker, Inc. (2026b). *Resource constraints*. https://docs.docker.com/engine/containers/resource_constraints/
+
+The Linux Kernel Organization. (2026). *Control group v2*. https://docs.kernel.org/admin-guide/cgroup-v2.html
diff --git a/docs/doctoring/python-mathematical-compute-boundary-audit.md b/docs/doctoring/python-mathematical-compute-boundary-audit.md
index f1dbeee83..7bb086cc6 100644
--- a/docs/doctoring/python-mathematical-compute-boundary-audit.md
+++ b/docs/doctoring/python-mathematical-compute-boundary-audit.md
@@ -3,13 +3,13 @@
**Exact-head audit date:** 2026-08-25
**Normative decision:** [ADR 0208](../adr/0208-externalize-local-mathematical-compute.md)
-This inventory names migration debt; it is not evidence that the current
-Python paths satisfy the Rust/GPU requirement.
+This inventory names remaining migration debt and completed owner slices; it
+does not relabel still-local Python paths as Rust/GPU compliant.
## Product-boundary sources read
-- LineageWeave `ARCHITECTURE.md` and accepted ADRs 0003, 0132, 0145,
- 0200, 0201, and 0205. This exact head has no standalone canonical PRD.
+- LineageWeave `docs/product-requirements.md`, `ARCHITECTURE.md`, and accepted
+ ADRs 0003, 0062, 0132, 0145, 0200, 0201, and 0205.
- TEPP `docs/product/prd-v0.4-approved.md`, whose approved TRSL-TM scope
owns temporal, relational, multilingual, topic, event, and trajectory
measurement.
@@ -23,12 +23,14 @@ Python paths satisfy the Rust/GPU requirement.
| Current LineageWeave path | Local computation | Owner | Consumer replacement | Principal callers / tests |
|---|---|---|---|---|
| `lineageweave/channel_weight_estimation.py` | dichotomization, synthetic simulation, MLS2PLM input construction, expected item information and normalization | fast-mlsirm, conditional on TEPP anchor | versioned anchored-weight artifact; strict digest/convergence validation | estimation scripts, seed/server/rebuild paths; `tests/test_channel_weight_estimation.py`, estimator-script tests |
-| `lineageweave/period_report.py` | response matrix, GRM/GPCM fit/FIPC/EAP, likelihood, category expectation, information ordering | fast-mlsirm | period-measurement artifact with item bank, scores, uncertainty, diagnostics | report ingestion and demo seed; period-report and report API tests |
-| `lineageweave/leftover_pairs.py` | residual matrix, complete-case selection, SVD/Gabriel coordinates, distances, reconstruction, axis shares | fast-mlsirm | residual-interaction artifact with observed/expected identity and coverage | `period_report.py`, report ingestion/seed; `tests/test_leftover_pairs.py`, report tests |
-| `lineageweave/embedding_client.py` and `backend/app/post_chat_ingestion.py` | cosine similarity, vector norms, maximum semantic score | RankWeave retrieval-score contract | ranked evidence envelope over ABAC-visible semantic units | reconstruction text channel and Global Ask retrieval; embedding/post-chat tests |
+| `lineageweave/period_report.py` | response matrix and owner-call orchestration remain; local category expectation and duplicate likelihood arithmetic removed | fast-mlsirm | `polytomous_expected_response`; diagnostics-owned held-out log likelihood; full period artifact remains debt | report ingestion and demo seed; period-report and report API tests |
+| `lineageweave/leftover_pairs.py` | **migrated:** identifier projection and closest/farthest selection only | fast-mlsirm | protected-main Rust `residual_interaction_map` with residual, coverage, SVD/Gabriel coordinates, distances, reconstruction and shares | `period_report.py`, report ingestion/seed; owner contract and consumer projection tests |
+| `backend/app/post_chat_ingestion.py` | active Global Ask cosine, vector norm, maximum semantic score; the unused `embedding_client.py` cosine/max-pooling experiment is deleted | RankWeave or another accepted Rust retrieval-score owner | versioned ranked-evidence envelope over ABAC-visible semantic units; fail closed until accepted | Global Ask retrieval and post-chat tests |
| `lineageweave/knowledge_graph.py` | random walk with restart, convergence delta, adaptive relevance cutoff | RankWeave graph-ranking contract | ranked-node artifact with contribution and convergence evidence | related-person/entity API paths; knowledge-graph tests |
+| `lineageweave/channels.py` | local time-decay score and `SequenceMatcher` text similarity fallback | RankWeave similarity contract; TEPP supplies temporal evidence | owner-computed, provenance-bearing channel evidence | `reconstruct.py`; channel and reconstruction tests |
| `lineageweave/reconstruct.py` | channel-weight renormalization, candidate-score fusion and minimum-score decision | RankWeave fusion; TEPP supplies independent lineage criterion | accepted edge-ranking artifact; LineageWeave persists selected edge and channel provenance | lineage rebuild/start/seed/server; reconstruct, persistence, API tests |
-| `lineageweave/rankweave_client.py` | channel construction, token overlap, RRF weights and contribution arithmetic | RankWeave | strict ranking artifact exposing owner-computed contributions | `/api/rankings`, frontend Rankings; `tests/test_rankweave_client.py` and frontend tests |
+| `lineageweave/rankweave_client.py` | channel construction and token overlap remain; **owner-bound:** classic/weighted RRF and contribution arithmetic now come from RankWeave #47, whose Python core still awaits the required Rust CPU/GPU migration | RankWeave | Rust-backed strict ranking artifact exposing owner-computed contributions and owned channel construction | `/api/rankings`, frontend Rankings; `tests/test_rankweave_client.py` and frontend tests |
+| `lineageweave/corporate_hierarchy_resolution.py` | `SequenceMatcher` organization-name similarity, score threshold, and top-score selection | external entity-resolution owner contract required | unique/miss/tie catalog-resolution artifact with evidence and policy version | organization resolution ingestion; corporate-hierarchy and API tests |
`lineageweave/post_evaluation.py` imports fast-mlsirm only for its published
judge contract and `to_irt_row` projection. It performs no fitted numerical
@@ -38,6 +40,9 @@ import must be reviewed before LineageWeave's final wire-only state.
Validation-only uses of `math.isfinite` and database aggregation are not model
ownership and remain. Date ordering, counts, pagination, authorization, schema
validation, and presentation formatting also remain LineageWeave concerns.
+Exact JSON UTF-8 body length, server-advertised token/input ceilings, vector
+dimension equality, and finite-number checks in embedding backfill validate an
+owner envelope; they neither estimate token counts nor calculate similarity.
## Required owner contracts
@@ -62,3 +67,36 @@ labels do not replace foreign keys. Dashboard and post detail endpoints read
only accepted persisted rows and preserve source-post ABAC. Storybook covers
accepted, pending, failed, stale-digest, non-converged, hidden-evidence, and
multiple-membership cases before UI activation.
+
+## 2026-08-26 stacked-PR audit
+
+The exact reviewed heads were PR #692 `583059edcffe994b18a6fbf3cb3b00bf4647c2a3`,
+PR #693 `999063d22e60469227eeea308fee787683952cab`, and PR #694
+`296cbae6c9ac2839b0f5ff150ae02ebf4f726627`. The review used CodeGraph before
+diff inspection.
+
+- PR #692 adds evidence-span normalization, unique/miss/tie catalog binding,
+ persistence, and projection. It adds no statistical score, vector algebra,
+ fitted weight, or local model.
+- PR #693's Python code validates vector shape and finiteness, serializes the
+ exact UTF-8 request body, and chooses a prefix under an upstream-advertised
+ byte ceiling. Those are transport and schema-validation operations allowed
+ by ADR 0208, not token estimation or vector scoring. Tokenization, token
+ ranges, provider-limit packing, checked token totals, and shard construction
+ are owned by contextual-orchestrator's Rust/PyO3 extension pinned by the
+ Docker build. The owner follow-up PR #865 is stacked on the current owning
+ #857 branch and fails closed at an undecodable
+ token ceiling and preserves complete UTF-8 scalars when a nominal token
+ boundary divides their byte representation.
+- PR #694 delegates overlap counts and the shared eligible denominator to one
+ authorization-filtered SQL aggregate. Converting those returned counts to a
+ displayed percentage is presentation formatting, explicitly outside the
+ model-ownership inventory. It supplies no threshold, category weight,
+ probability model, or forced winner.
+
+No new Python mathematical or psychometric implementation was found in this
+stack. The highest-leverage newly exercised owner path is therefore the Rust
+token packer rather than a duplicate LineageWeave implementation. Existing
+time-decay and string similarity, cosine, graph-ranking, fusion, period-report,
+and anchored channel-weight debt remains frozen under the owner and acceptance
+criteria above; this audit does not reclassify it as complete.
diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md
index a0f202468..9f756450d 100644
--- a/docs/lineage-bi-research-notes.md
+++ b/docs/lineage-bi-research-notes.md
@@ -98,10 +98,9 @@ Embedding a whole flattened document as one vector dilutes a short
relevant unit with everything else in the same document -- the vector
averages over content that has nothing to do with the match being sought.
`lineageweave/chunking.py` splits a document into meaning-identifiable
-units first; `embedding_client.chunked_max_similarity` embeds every unit
-and takes the single highest-scoring pair, which is the standard
-passage-retrieval strategy for "a relevant unit is buried in a longer
-document." Four unit types, each grounded in a real boundary concept:
+units first. ADR 0208 removed the unused local Python cosine/max-pooling
+experiment; a versioned Rust retrieval-owner envelope must perform any future
+unit scoring. Four unit types remain, each grounded in a real boundary concept:
- **paragraph** -- subtopic-passage boundaries (Hearst, 1997, TextTiling).
- **sentence** -- the finer unit inside a paragraph.
@@ -114,10 +113,8 @@ document." Four unit types, each grounded in a real boundary concept:
**Honest scope note for this project's real dataset**: the real dataset
validated against in milestone 2 (43,814 short business records) has only
one real free-text field, and it is short (~28 characters average) with no
-paragraph, DOM, or conversation structure to chunk -- chunking a title
-does nothing useful and `chunked_max_similarity` degrades gracefully to
-plain whole-text embedding for exactly this case (a document that chunks
-to zero or one piece is embedded once, same as before chunking existed).
+paragraph, DOM, or conversation structure to chunk, so unit persistence does
+not imply or fabricate a local similarity score.
This module exists for when a richer content source is embedded --
concretely, the raw MHTML source artifacts this dataset's records were
derived from (tracked only as opaque content-addressed references in this
diff --git a/docs/manuals/mcp-manual.md b/docs/manuals/mcp-manual.md
new file mode 100644
index 000000000..00db1ebf1
--- /dev/null
+++ b/docs/manuals/mcp-manual.md
@@ -0,0 +1,106 @@
+# LineageWeave MCP manual
+
+LineageWeave exposes authenticated, asynchronous Global Ask over Streamable
+HTTP. MCP and the browser use the same durable Ask jobs, access rules, status
+values, citations, related public sources, limitations, and knowledge cutoff.
+
+## Before connecting
+
+Ask the deployment operator for:
+
+- the HTTPS MCP resource URL;
+- the exact OAuth resource audience and required scopes; and
+- an access token issued for that resource to a provisioned LineageWeave
+ account with record-read permission.
+
+Do not reuse a browser client secret, provider credential, or analysis-service
+key as an MCP credential. Clients must preserve the `Mcp-Session-Id` returned
+by initialization and send it on subsequent requests.
+
+For local synthetic testing only, the optional Compose profile exposes
+`http://localhost:18001/mcp`. Start it after the operator has supplied quota
+values derived from that deployment's k6 evidence:
+
+```bash
+MCP_RATE_LIMIT_REQUESTS= \
+MCP_RATE_LIMIT_WINDOW_SECONDS= \
+docker compose --profile mcp up -d mcp
+```
+
+## Tools
+
+### `submit_global_ask`
+
+Queues a question and returns without waiting for analysis.
+
+| Argument | Required | Meaning |
+| --- | --- | --- |
+| `question` | yes | The question to answer from authorized evidence. |
+| `verify_external` | no | Compare eligible public claims with public sources. Defaults to `false`. |
+| `knowledge_cutoff` | no | ISO-8601 cutoff; evidence later than this instant is excluded. |
+
+Save the returned `ask_job_id`. Submission is not an answer and clients must
+not repeat it merely because the job remains queued or running.
+
+### `read_global_ask_job`
+
+Reads one job owned by the authenticated account.
+
+| Argument | Required | Meaning |
+| --- | --- | --- |
+| `ask_job_id` | yes | UUID returned by `submit_global_ask`. |
+
+Poll with bounded backoff until the status is terminal. A completed result can
+include cited records, event cards, images, report and alert delivery, and
+`cited_source_references`. Open only the returned URLs; absence of a title or
+URL is an unavailable source, not permission to synthesize one.
+
+## Status and recovery
+
+| Observation | Client action |
+| --- | --- |
+| queued or running | Keep the job id and poll later with bounded backoff. |
+| succeeded | Render the persisted answer and keep citations linked to their record ids. If the result has no authorized citations, show its limitation and next action; do not turn the empty evidence set into an answer. |
+| failed | Show the returned safe failure detail and allow a new submission after the operator restores the dependency. |
+| 401 | Renew the resource token, initialize a new MCP session, and retry the read. |
+| 403 | Request the required permission or affiliation; do not broaden the query locally. |
+| not found | Confirm the job id and account. Jobs are owner-scoped. |
+| rate limited | Wait for the returned `Retry-After` interval. |
+| limiter unavailable | Retry later; the service cannot safely admit the call. |
+
+Never infer a completed result from a transport timeout. Re-read the saved job
+id after connectivity returns.
+
+## Response handling
+
+- Preserve each citation's record id and event-clock metadata when rendering
+ the answer.
+- A succeeded job with no authorized citations is an honest no-evidence result,
+ not permission to fill the gap. Keep the result, suggest narrowing the
+ question, and ask the account administrator to confirm access when the user
+ expected an eligible record.
+- Render related public sources only from the persisted citation payload.
+- Treat an unavailable TEPP or topic/importance measurement as unavailable;
+ do not manufacture a score, weight, or journey edge.
+- Do not log bearer tokens, prompts, answers, source text, provider responses,
+ tenant identifiers, or raw MCP session ids.
+- Keep provider selection outside the MCP client. LineageWeave accepts no
+ client-selected provider model.
+
+## End-to-end capacity check
+
+Use the repository's synthetic harness with explicit observation bounds:
+
+```bash
+LINEAGEWEAVE_VUS= \
+LINEAGEWEAVE_DURATION= \
+LINEAGEWEAVE_REQUEST_TIMEOUT= \
+make load-mcp
+```
+
+The output is deployment evidence, not a universal SLO. Set production quota
+values only from a representative run whose environment, concurrency,
+duration, job-state counts, and bottleneck observations are retained outside
+the repository without source records or identifiers.
+
+See the [operations manual](operations-manual.md) for deployment and recovery.
diff --git a/docs/manuals/operations-manual.md b/docs/manuals/operations-manual.md
new file mode 100644
index 000000000..92273d21e
--- /dev/null
+++ b/docs/manuals/operations-manual.md
@@ -0,0 +1,248 @@
+# LineageWeave operations manual
+
+This manual is for deployment operators. It separates customer-visible
+recovery actions from service ownership, authorization, and evidence handling.
+Use synthetic data for repository tests and demonstrations; never copy runtime
+records, credentials, prompts, answers, or identifiers into git artifacts.
+
+## Service ownership
+
+| Concern | Owner and operator action |
+| --- | --- |
+| Identity and access | Keyverse in production; bundled Keycloak only for standalone/local/dev/test. Configure one authority and verify its exact audience and claims. |
+| LLM, vision, embeddings, structured output | contextual-orchestrator. Restore its provider-neutral endpoint; do not select or hardcode a provider model in LineageWeave. |
+| Temporal and psychometric measurement | TEPP and fast-mlsirm. Accept only versioned, completed, provenance-bearing results. Keep the feature unavailable otherwise. |
+| Event reconstruction and product evidence | LineageWeave. Preserve source provenance, ABAC, durable job state, and cited evidence. |
+| Ranking and reference threading | RankWeave and ThreadWeave through their published contracts; do not duplicate their algorithms locally. |
+
+## Start and verify the canonical stack
+
+Compose declares the project name `lineageweave`. Credentials remain in
+`~/.env`; do not print or copy that file into the checkout.
+
+```bash
+make up
+make ps
+make smoke
+make seed # synthetic local data only
+curl --fail http://localhost:18420/healthz
+```
+
+The default stack includes the durable worker. `/healthz` proves only process
+liveness, so also confirm that `backend-worker` is progress-healthy before
+opening the frontend. In production, set the Keyverse issuer/audience values;
+do not combine central Keyverse and the bundled realm as simultaneous
+authorization authorities.
+
+An isolated test may use `docker compose -p ...`. After the
+test, run `docker compose -p down` without `-v` unless the
+approved procedure explicitly retires its data. Remove exited test containers
+after their evidence has been retained. Do not run a second long-lived copy of
+the canonical stack under a different project name.
+
+## Configure optional integrations
+
+- Set `ORCHESTRATOR_BASE_URL` and `ORCHESTRATOR_API_KEY` for the internal
+ LineageWeave-to-orchestrator connection. Provider credentials remain in the
+ orchestrator environment.
+- Set `TEPP_TRANSPORT_URL` and its runtime credential only when the accepted
+ TEPP producer contract is deployed. A configured URL is not proof of an
+ accepted result.
+- Enable the `mcp` Compose profile only after setting exact OAuth resource,
+ Host/Origin, request-size, and k6-evidenced quota values described in the
+ [MCP manual](mcp-manual.md).
+
+## Promote an orchestrator revision safely
+
+Use `scripts/promote_contextual_orchestrator.sh` for an exact reviewed
+revision. Declare `ALLOW_PROVIDER_CALLS=1`, the full
+`EXPECTED_ORCHESTRATOR_REVISION`, and bounded startup, per-agent probe, and
+readiness observation times. The script builds and starts an isolated
+candidate with the current `~/.env`, verifies its revision label, and requires
+at least one authenticated configured-gateway model from the upstream
+administrator readiness report before recreating the canonical service.
+The existing canonical service remains untouched when preflight fails.
+
+If preflight reports HTTP 401 or that the configured gateway did not
+authenticate, verify the currently authorized gateway credential and endpoint
+in `~/.env` without printing either value. Have the credential owner renew or
+correct that current runtime entry, then rerun the complete isolated preflight.
+Do not copy a credential into the repository, add a second credential source,
+change the expected revision, or recreate the canonical service to bypass the
+failure.
+
+For authenticated runtime acceptance, start the exact-revision stack with the
+MCP profile and declare the bounded readiness observation budget. The
+promotion runner asks contextual-orchestrator's administrator readiness
+endpoint to refresh its own provider probes and fails closed unless at least
+one configured-gateway model authenticates. It never calls the configured
+provider directly, persists the ephemeral administrator credential, or
+recreates the canonical service before the isolated candidate passes.
+
+Declare `OPERATIONS_CASE_ACCEPTANCE_TIMEOUT_SECONDS` and
+`OPERATIONS_CASE_POLL_SECONDS` as separate positive-integer observation inputs.
+The runner does not enqueue a demonstration record or assume a fresh ledger. It
+first accepts aggregate grounded evidence produced since the exact worker
+container started. If none exists yet, an eligible queued/running record with no
+current-source-digest analysis must already be present; the runner then waits
+for both deployment-bound analysis and grounded aggregate counts to advance.
+It fails closed when neither path is available. Source rows and record
+identifiers remain inside the database and are never printed.
+
+The 2026-08-26 diagnostic run supplied `MCP_RATE_LIMIT_REQUESTS=1000` and
+`MCP_RATE_LIMIT_WINDOW_SECONDS=60` only to its acceptance invocation. Those
+observed inputs are neither source defaults nor a production capacity SLO;
+repeat k6 measurement in the target deployment before selecting production
+quota values.
+
+## Durable asynchronous work
+
+The API enqueues Ask and content-analysis work; workers perform provider calls
+outside pooled database transactions. `backend-worker` owns post-content,
+analysis-run, Voice-transition, and configured topic-influence work;
+`backend-ask-worker` owns only Global Ask. Keep `backend-worker` enabled during
+an admitted backfill. If post-content must be stopped, stop only that service;
+the Ask worker must remain enabled and cannot recover or consume post-content.
+Starting `backend` directly starts and health-gates only the Ask worker; it does
+not admit post-content or require the broad worker.
+Stopping a worker does not turn queued work into a completed analysis.
+
+When upgrading from the former single-worker image, stop `backend-worker`
+before recreating either selected worker. The old process-wide advisory lease
+does not overlap the per-consumer lease names, so running old and new worker
+images together is prohibited. Confirm the old container is stopped, then
+start `backend-ask-worker`; start `backend-worker` only when its queues are
+admitted for processing.
+
+For an incident:
+
+1. Preserve the job id and inspect aggregate job-state counts without printing
+ source content or account identifiers.
+2. Confirm backend-worker progress health, Valkey availability, PostgreSQL
+ connectivity, and the owner service's readiness.
+3. Restore the failed dependency before retrying. Do not convert an unavailable
+ provider response into a negative classification.
+4. The enabled worker admits the next bounded incomplete page every recovery
+ cycle. For an operator-controlled catch-up, run
+ `scripts/queue_post_content_backfill.py --all-pages`. After restoring a
+ terminal dependency, run `scripts/queue_post_content_backfill.py
+ --retry-failed` for one bounded page. Do not combine those flags: observe
+ aggregate worker, PostgreSQL, Valkey, and orchestrator health until the page
+ settles before choosing whether to admit another terminal page. Both modes
+ persist each page before publishing wake-ups and report aggregate counts
+ only.
+ The post-content consumer is intentionally serial. Do not derive a worker
+ concurrency value from low CPU or memory while it waits on the provider:
+ there is no measured gateway concurrency envelope, and the database pool is
+ shared with the worker lease and other durable consumers. A page can take
+ one provider deadline per attempted record; keep observing the admitted page
+ rather than opening another one.
+5. For one terminal content job, run
+ `uv run python scripts/requeue_failed_post_content.py --post-id `
+ from the governed operator environment. This preserves the original source
+ digest, orchestrator session lineage, and idempotency boundary. Do not edit
+ queue rows or publish a wake-up manually.
+5. Verify the affected aggregate returns to completed and that no partial
+ result became visible.
+
+One record uses the same bounded post-scoped orchestrator session lineage for
+its related analysis work. Treat those session values as correlation metadata:
+retain them in governed storage, do not expose or log them as customer content.
+
+## Dashboard and semantic evidence recovery
+
+- **Pending count grows:** verify worker progress, queue publication, and
+ owner-service readiness; do not add more HTTP workers as a substitute for
+ consumers.
+- **Failed count grows:** inspect safe failure categories and retry through the
+ durable queue after the root cause is fixed.
+- **Voice counts are unavailable:** confirm that current source and derived
+ assertions completed. The twelve governed Voice codes are multi-label.
+ A current derived completion requires the current source digest and a
+ non-empty analysis receipt; a receipted empty result is a completed analysis
+ with zero supported derived memberships, while a missing receipt remains
+ unavailable for bounded retry. Source and derived histories remain separate,
+ and cutoff reads use the assignment effective then. Retry the Voice stage
+ after its dependency recovers even when operations analysis completed or
+ failed separately. Preserve multi-membership and disagreement; do not coerce
+ a record into one category.
+- **Product mention is missing, tied, or unavailable:** repair or review the
+ governed product catalog and rerun product extraction. A current completion
+ requires its analysis receipt plus current source and eligible-target
+ digests. Product extraction runs independently of operations-case analysis,
+ so retry its own stage after a product failure and do not wait for or
+ fabricate an operations result. Historical rows without a receipt remain
+ unavailable. Do not bind by display-name similarity alone.
+- **A governed product is absent:** an account with `post_admin` submits
+ `PUT /api/product-catalog/{product_code}` with the explicit product-master
+ label, level, source organization/system/record, optional existing parent,
+ and source-supported aliases. Preserve the returned digest with the import
+ evidence. On `409`, reconcile the source-master conflict instead of changing
+ the catalog implicitly; on `422`, provision the named parent or correct the
+ invalid row. Then rerun product analysis and open the cited post to verify the
+ connection.
+- **Project journey is unavailable:** first verify that the source record's
+ exact non-empty `source_project_code` was imported and remains visible under
+ the caller's access. Do not bind a project from similar names or text. Then,
+ when temporal corroboration is expected, verify an accepted TEPP result for
+ the exact snapshot, input digest, and cutoff. Do not substitute chronological
+ sorting or reinterpret temporal evidence as a business transition.
+- **Related public source is absent:** verify publication eligibility and the
+ governed public-research service. Do not invent or manually insert a title,
+ URL, or excerpt.
+
+## Database checks
+
+Observe PostgreSQL before changing it. Record only aggregates:
+
+- active and waiting sessions by wait-event class;
+- transaction age and lock blockers;
+- queue-state totals and oldest queued age;
+- WAL growth/checkpoint statistics; and
+- query plans through the repository's bounded `EXPLAIN` procedure.
+
+Do not cancel a migration or disable WAL durability solely because it is slow.
+Use `scripts/explain_post_content_backfill.py` for the bounded backfill plan;
+it rolls back and reports aggregate timing, buffers, temporary blocks, WAL,
+node kinds, and relation scans without exposing rows. Tune only from measured
+evidence, then capture the root-cause fix in Compose/configuration and tests.
+The observed PostgreSQL tuning procedure revalidates exact settings, aggregate
+transaction/lock quiescence, and current cgroup/disk capacity immediately
+before any approved restart; a saved plan is not permission to reuse stale
+runtime evidence.
+
+## Load and responsiveness verification
+
+With the canonical synthetic stack healthy, declare the environment-specific
+concurrency, duration, and timeout:
+
+```bash
+LINEAGEWEAVE_VUS= \
+LINEAGEWEAVE_DURATION= \
+LINEAGEWEAVE_REQUEST_TIMEOUT= \
+make load-http
+
+LINEAGEWEAVE_VUS= \
+LINEAGEWEAVE_DURATION= \
+LINEAGEWEAVE_REQUEST_TIMEOUT= \
+make load-mcp
+```
+
+Retain aggregate request rates, latency distributions, functional-check
+failures, Ask job-state counts, CPU, memory, database waits, and worker backlog
+outside git. These observations do not establish a production SLO until the
+named deployment and representative workload approve one.
+
+## Shutdown and rollback
+
+```bash
+make down
+```
+
+Do not remove named volumes during ordinary shutdown. Apply migration rollback
+files only under the migration-specific reviewed recovery plan; application
+code must not compensate for a missing table. After recovery, repeat OIDC,
+authenticated API, worker-progress, Dashboard, Ask, and relevant k6 checks at
+the exact deployed revision.
+
+Customer actions are documented separately in the [user guide](user-guide.md).
diff --git a/docs/manuals/user-guide.md b/docs/manuals/user-guide.md
new file mode 100644
index 000000000..c5ad2a30d
--- /dev/null
+++ b/docs/manuals/user-guide.md
@@ -0,0 +1,120 @@
+# LineageWeave user guide
+
+This guide describes the actions available in the authenticated workspace.
+What you can see depends on your role and organizational access. If a count,
+record, or citation is absent, ask an administrator to confirm your access
+before drawing a conclusion from the absence.
+
+## Start with the Dashboard
+
+After signing in, use **Dashboard** to review the selected period.
+
+1. Set the inclusive start and end dates, then choose **Apply period**.
+2. Compare the record count with the Event count. One record can contain more
+ than one Event, so the two totals answer different questions.
+3. Open a case card or its evidence action to read the cited record.
+4. Review **pending analysis** and **failed analysis** separately. Ask an
+ administrator to retry failed work before treating a missing case as a
+ confirmed zero.
+
+Use the claim cards to trace the received claim, originating order,
+specification change, sales pool, and cause-confirmation evidence. Use the
+rebid and handover cards to review discussions, participants, your owner, and
+the decisions that followed. The external-information destination applies the
+same period and access rules while showing procurement and market evidence;
+there is no second board to reconcile.
+
+Project sections show the observed records and, when accepted journey evidence
+exists, the supported start, predecessor, branch, and transition. Open each
+milestone before acting: a lead, public notice, customer request, negotiated
+bid, discussion, or earlier project may precede the first order shown on
+screen. A record joins a project journey only when its source carries the exact
+project code. If a known project has no journey, ask a source-data steward to
+confirm that code was recorded and imported; do not infer membership from a
+similar project name or nearby record.
+
+## Review Voice evidence
+
+The Dashboard counts all supported Voice memberships over the records you can
+see. A record may support several categories, so category totals can overlap.
+
+| Code | Meaning |
+| --- | --- |
+| VOC | Voice of Customer |
+| VOCC | Voice of Customer's Customer |
+| VOCO | Voice of Competitor |
+| VOM | Voice of Market |
+| VOP | Voice of Partner |
+| VOS | Voice of Supplier |
+| VOE | Voice of Employee |
+| VOB | Voice of Business |
+| VOR | Voice of Regulator |
+| VOI | Voice of Investor |
+| VOSO | Voice of Society |
+| VOPS | Voice of Process |
+
+Review multi-category records, source-versus-derived disagreements, and
+records without supporting evidence before using a category total. A record's
+Voice category does not by itself establish how every organization mentioned
+in that record relates to your organization. Source categories and supported
+derived categories remain separate. A derived category appears only with its
+cited source passage and completed analysis; unavailable analysis is not a
+negative category. At a knowledge cutoff, review the category history that was
+effective then instead of applying today's category retrospectively.
+
+## Ask with evidence
+
+Open **Ask Agent**, enter a specific question, and optionally choose a
+knowledge cutoff. Submission returns immediately while the answer is prepared.
+Keep the workspace open or return later to read the durable job result.
+
+When the answer appears:
+
+1. Select a numbered citation to focus its event card.
+2. Open the cited record to read the complete authorized source.
+3. Open **Related public sources** to compare the persisted public original
+ and excerpt. A missing link means no eligible related source is available;
+ the product does not create a title or URL.
+4. Read limitations and the suggested next action before forwarding a report
+ or acting on an alert.
+
+A completed answer with no authorized citations is an honest no-evidence
+result. Narrow the question or ask an administrator to confirm your access;
+do not treat uncited text as evidence or fill the gap from memory.
+
+Enable public verification only when the question contains a claim that needs
+comparison with public information. If verification is unavailable, ask an
+administrator to enable the governed public-research service and retry. A
+knowledge cutoff excludes later evidence rather than substituting today's
+record text.
+
+## Inspect a record
+
+Open a record from the Dashboard, Board, search, calendar, or an Ask citation.
+Use its evidence sections to:
+
+- compare the source body with derived paragraphs and image regions;
+- review product mentions at group, model, variant, or trade-item level;
+- ask the product-catalog steward to review a mention marked tied, missing, or
+ unavailable before using its relationship;
+- request product reprocessing when its analysis is unavailable, even if case
+ analysis failed separately; one result does not stand in for the other;
+- inspect similar prior issues and their cited actions; and
+- follow Event Lineage without treating ontology neighbors as parent records.
+
+Do not use an unavailable product, topic, journey, or measurement result as a
+negative finding. Open the cited evidence or request reprocessing first.
+
+## When a result is unavailable
+
+- **Analysis pending:** wait for completion, then refresh.
+- **Analysis failed:** ask an administrator to retry the failed job.
+- **Ask unavailable:** ask an administrator to restore the analysis service,
+ then submit again.
+- **No authorized evidence:** narrow the question or ask an administrator to
+ confirm your organizational access.
+- **Measurement unavailable:** continue with cited descriptive evidence; do
+ not interpret the missing measurement as zero.
+
+For setup and incident recovery, use the [operations manual](operations-manual.md).
+For an MCP client, use the [MCP manual](mcp-manual.md).
diff --git a/docs/ontology/lineageweave-kg-shapes.ttl b/docs/ontology/lineageweave-kg-shapes.ttl
index 2a5b6fbab..7ddad063e 100644
--- a/docs/ontology/lineageweave-kg-shapes.ttl
+++ b/docs/ontology/lineageweave-kg-shapes.ttl
@@ -1,6 +1,7 @@
@prefix : .
@prefix dcterms: .
@prefix owl: .
+@prefix prov: .
@prefix rdf: .
@prefix rdfs: .
@prefix sh: .
@@ -154,6 +155,7 @@
sh:path :semanticConfidence ;
sh:name "semantic confidence" ;
sh:description "Extraction confidence stays inside [0.0, 1.0] inclusive." ;
+ sh:minCount 1 ;
sh:maxCount 1 ;
sh:datatype xsd:decimal ;
sh:minInclusive 0.0 ;
@@ -162,9 +164,31 @@
sh:property [
sh:path :projectEvidence ;
sh:name "project evidence" ;
- sh:description "At most one verbatim evidence span per mention; missing evidence is an honest unknown, never zero-filled." ;
+ sh:description "Every projected mention retains its one verbatim source span." ;
+ sh:minCount 1 ;
sh:maxCount 1 ;
sh:datatype xsd:string ;
+ sh:minLength 1 ;
+ ] ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ;
+ sh:name "project mention source" ;
+ sh:minCount 1 ; sh:maxCount 1 ; sh:class :Post ;
+ ] ;
+ sh:property [
+ sh:path prov:generatedAtTime ;
+ sh:name "project mention recorded at" ;
+ sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:dateTime ;
+ ] ;
+ sh:sparql [
+ a sh:SPARQLConstraint ;
+ sh:message "Project mention provenance must identify the same Post as rdf:subject." ;
+ sh:select """
+ SELECT $this WHERE {
+ $this rdf:subject ?post ; prov:wasDerivedFrom ?source .
+ FILTER (?post != ?source)
+ }
+ """ ;
] .
:VoiceAssignmentShape a sh:NodeShape ;
@@ -176,7 +200,13 @@
sh:description "Every qualified assignment names exactly one governed atomic Voice-of-X concept." ;
sh:minCount 1 ;
sh:maxCount 1 ;
- sh:class ;
+ sh:in (
+ :voiceOfCustomerType :voiceOfCustomersCustomerType
+ :voiceOfCompetitorType :voiceOfMarketType :voiceOfPartnerType
+ :voiceOfSupplierType :voiceOfEmployeeType :voiceOfBusinessType
+ :voiceOfRegulatorType :voiceOfInvestorType :voiceOfSocietyType
+ :voiceOfProcessType
+ ) ;
] ;
sh:property [
sh:path :primaryVoiceAssignment ;
@@ -193,6 +223,31 @@
sh:minCount 1 ;
sh:maxCount 1 ;
sh:class :Post ;
+ ] ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ;
+ sh:name "voice assignment provenance" ;
+ sh:minCount 1 ; sh:maxCount 1 ; sh:class :Post ;
+ ] ;
+ sh:property [
+ sh:path :truthStatus ;
+ sh:name "voice assignment truth status" ;
+ sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in (
+ "truth_authoritative" "truth_observed" "truth_inferred"
+ "truth_proposed" "truth_superseded" "truth_rejected"
+ ) ;
+ ] ;
+ sh:sparql [
+ a sh:SPARQLConstraint ;
+ sh:message "Voice assignment evidence and PROV source must identify the same Post." ;
+ sh:select """
+ SELECT $this WHERE {
+ $this :voiceAssignmentEvidence ?evidence ;
+ prov:wasDerivedFrom ?source .
+ FILTER (?evidence != ?source)
+ }
+ """ ;
] .
:OccupationalConstructAssertionShape a sh:NodeShape ;
@@ -247,6 +302,212 @@
""" ;
] .
+:OperationsCaseShape a sh:NodeShape ;
+ rdfs:label "Evidence-grounded operations case shape" ;
+ sh:targetClass :ClaimInvestigation, :RebidHandover, :ExternalInformation, :RepeatIssue ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ;
+ sh:minCount 1 ; sh:maxCount 1 ; sh:class :Post ;
+ ] ;
+ sh:property [
+ sh:path :hasOperationsFact ; sh:class :OperationsCaseFact ;
+ ] .
+
+:OperationsCaseFactShape a sh:NodeShape ;
+ rdfs:label "Evidence-grounded operations fact shape" ;
+ # Product-relation fragments may carry only a typed reference to an
+ # OperationsCaseFact. Validate complete Dashboard facts when their
+ # factTypeCode marks the authoritative fact projection.
+ sh:targetSubjectsOf :factTypeCode ;
+ sh:property [
+ sh:path :factTypeCode ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in (
+ "order" "specification_change" "originating_order" "sales_pool"
+ "discussion" "counterparty" "our_owner" "decision"
+ "external_relation" "issue_pattern" "improvement_action"
+ ) ;
+ ] ;
+ sh:property [
+ sh:path :factValue ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:minLength 1 ;
+ ] ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ;
+ sh:minCount 1 ; sh:maxCount 1 ; sh:class :Post ;
+ ] ;
+ sh:or (
+ [
+ sh:property [ sh:path rdf:subject ; sh:maxCount 0 ] ;
+ sh:property [ sh:path rdf:predicate ; sh:maxCount 0 ] ;
+ sh:property [ sh:path rdf:object ; sh:maxCount 0 ] ;
+ ]
+ [
+ sh:property [ sh:path rdf:subject ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :ExternalInformation ] ;
+ sh:property [ sh:path rdf:predicate ; sh:minCount 1 ; sh:maxCount 1 ; sh:hasValue :relatesToOrder ] ;
+ sh:property [ sh:path rdf:object ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :Order ] ;
+ ]
+ [
+ sh:property [ sh:path rdf:subject ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :ExternalInformation ] ;
+ sh:property [ sh:path rdf:predicate ; sh:minCount 1 ; sh:maxCount 1 ; sh:hasValue :relatesToProject ] ;
+ sh:property [ sh:path rdf:object ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :Project ] ;
+ ]
+ [
+ sh:property [ sh:path rdf:subject ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :ExternalInformation ] ;
+ sh:property [ sh:path rdf:predicate ; sh:minCount 1 ; sh:maxCount 1 ; sh:hasValue :relatesToSales ] ;
+ sh:property [ sh:path rdf:object ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :SalesContext ] ;
+ ]
+ [
+ sh:property [ sh:path rdf:subject ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :ExternalInformation ] ;
+ sh:property [ sh:path rdf:predicate ; sh:minCount 1 ; sh:maxCount 1 ; sh:hasValue :relatesToBusinessManagement ] ;
+ sh:property [ sh:path rdf:object ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :BusinessManagementContext ] ;
+ ]
+ ) .
+
+:ProductMentionShape a sh:NodeShape ;
+ rdfs:label "Evidence-bound product mention shape" ;
+ sh:targetClass :ProductMention ;
+ sh:property [
+ sh:path :extractedProductName ;
+ sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:minLength 1 ;
+ ] ;
+ sh:property [
+ sh:path :productResolutionStatus ;
+ sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in ("unique" "missing" "tie" "unavailable") ;
+ ] ;
+ sh:property [
+ sh:path :evidenceInputDigest ;
+ sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ;
+ sh:minCount 1 ; sh:maxCount 1 ;
+ sh:class :Post ;
+ ] .
+
+:CatalogProductShape a sh:NodeShape ;
+ rdfs:label "Governed catalog product shape" ;
+ sh:targetClass :CatalogProduct ;
+ sh:property [
+ sh:path :productCatalogCode ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:minLength 1 ;
+ ] ;
+ sh:property [
+ sh:path :preferredProductLabel ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:minLength 1 ;
+ ] ;
+ sh:property [
+ sh:path :productLevelCode ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in ("product_group" "product_model" "variant" "trade_item") ;
+ ] ;
+ sh:property [
+ sh:path :parentProduct ; sh:maxCount 1 ; sh:nodeKind sh:IRI ;
+ ] .
+
+:ProductRelationAssertionShape a sh:NodeShape ;
+ rdfs:label "Evidence-bound product relation shape" ;
+ sh:targetClass :ProductRelationAssertion ;
+ sh:property [ sh:path rdf:subject ; sh:minCount 1 ; sh:maxCount 1 ] ;
+ sh:property [
+ sh:path rdf:predicate ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in (:concernsProduct :changesProduct :originatesFromProduct :sensesProduct :usesProduct) ;
+ ] ;
+ sh:property [ sh:path rdf:object ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :Product ] ;
+ sh:property [
+ sh:path :productRelationEvidence ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:minLength 1 ;
+ ] ;
+ sh:property [
+ sh:path :evidenceInputDigest ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :Post ;
+ ] ;
+ sh:or (
+ [
+ sh:property [
+ sh:path rdf:predicate ;
+ sh:in (:concernsProduct :changesProduct :originatesFromProduct :sensesProduct) ;
+ ] ;
+ sh:property [
+ sh:path rdf:subject ; sh:class :OperationsCaseFact ;
+ sh:name "operational product relation subject" ;
+ ]
+ ]
+ [
+ sh:property [ sh:path rdf:predicate ; sh:hasValue :usesProduct ] ;
+ sh:property [
+ sh:path rdf:subject ; sh:class :Project ;
+ sh:name "project product relation subject" ;
+ ]
+ ]
+ ) .
+
+:PostVoiceClassificationAssertionShape a sh:NodeShape ;
+ sh:targetClass :PostVoiceClassificationAssertion ;
+ sh:property [
+ sh:path :voiceConceptCode ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in (
+ "voc" "vocc" "voco" "vom" "vop" "vos"
+ "voe" "vob" "vor" "voi" "voso" "vops"
+ ) ;
+ ] ;
+ sh:property [
+ sh:path :voiceAssertionStatus ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in ("source" "derived") ;
+ ] ;
+ sh:property [
+ sh:path :voiceEvidenceDigest ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [
+ sh:path :sourceRevisionDigest ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :Post ;
+ ] ;
+ sh:property [ sh:path :validFrom ; sh:maxCount 1 ; sh:datatype xsd:dateTime ; sh:lessThanOrEquals :validTo ] ;
+ sh:property [ sh:path :validTo ; sh:maxCount 1 ; sh:datatype xsd:dateTime ] ;
+ sh:or (
+ [ sh:property [ sh:path :voiceAssertionStatus ; sh:hasValue "source" ] ]
+ [
+ sh:property [ sh:path :voiceAssertionStatus ; sh:hasValue "derived" ] ;
+ sh:property [ sh:path :orchestratorModelReceipt ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:string ; sh:minLength 1 ] ;
+ sh:property [ sh:path :evidenceSpanStart ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ; sh:minInclusive 0 ; sh:lessThan :evidenceSpanEnd ] ;
+ sh:property [ sh:path :evidenceSpanEnd ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ]
+ ]
+ ) .
+
+:OrganizationVoiceRelationshipAssertionShape a sh:NodeShape ;
+ sh:targetClass :OrganizationVoiceRelationshipAssertion ;
+ sh:property [
+ sh:path :voiceConceptCode ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in ("rel_voc" "rel_vocc" "rel_voco" "rel_vom" "rel_vop" "rel_vos") ;
+ ] ;
+ sh:property [
+ sh:path :orchestratorModelReceipt ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:minLength 1 ;
+ ] ;
+ sh:property [
+ sh:path :voiceEvidenceDigest ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [
+ sh:path :sourceRevisionDigest ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [ sh:path :evidenceSpanStart ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ; sh:minInclusive 0 ; sh:lessThan :evidenceSpanEnd ] ;
+ sh:property [ sh:path :evidenceSpanEnd ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ] ;
+ sh:property [ sh:path :validFrom ; sh:maxCount 1 ; sh:datatype xsd:dateTime ; sh:lessThanOrEquals :validTo ] ;
+ sh:property [ sh:path :validTo ; sh:maxCount 1 ; sh:datatype xsd:dateTime ] ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :Post ;
+ ] .
+
:OurSidePersonShape a sh:NodeShape ;
rdfs:label "Our-side person shape" ;
sh:comment "Closed-world complement of :OurSidePerson owl:disjointWith :CounterpartyPerson: an instance of one can never be typed as the other." ;
diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl
index 067462572..3b05756f4 100644
--- a/docs/ontology/lineageweave-kg.ttl
+++ b/docs/ontology/lineageweave-kg.ttl
@@ -367,7 +367,27 @@
# combination. Additional assignments use prov:wasDerivedFrom to retain their
# evidence lineage.
:VoiceAssignment a owl:Class ;
- rdfs:subClassOf prov:Entity ;
+ rdfs:subClassOf prov:Entity,
+ [ a owl:Restriction ;
+ owl:onProperty :assignedVoiceType ;
+ owl:allValuesFrom [
+ a owl:Class ;
+ owl:oneOf (
+ :voiceOfCustomerType
+ :voiceOfCustomersCustomerType
+ :voiceOfCompetitorType
+ :voiceOfMarketType
+ :voiceOfPartnerType
+ :voiceOfSupplierType
+ :voiceOfEmployeeType
+ :voiceOfBusinessType
+ :voiceOfRegulatorType
+ :voiceOfInvestorType
+ :voiceOfSocietyType
+ :voiceOfProcessType
+ )
+ ]
+ ] ;
rdfs:label "Voice assignment"@en ;
rdfs:comment "One atomic Voice-of-X classification attached to a post with its own truth and provenance contract."@en .
@@ -393,6 +413,11 @@
rdfs:label "voice assignment evidence"@en ;
rdfs:comment "The authorized source post that supports this qualified voice assignment."@en .
+:truthStatus a owl:DatatypeProperty ;
+ rdfs:range xsd:string ;
+ rdfs:label "truth status"@en ;
+ rdfs:comment "The governed ontology truth state carried by nodes, reified edges, and qualified Voice assignments; no domain is declared because these are distinct resource kinds."@en .
+
#################################################################
# SKOS -- corporate_entity_level (Group -> Company -> Plant)
#################################################################
@@ -581,7 +606,165 @@
# do NOT carry :lookupCode: they are not common_lookup_value rows, so
# the lookup-code round trip is unaffected.
#################################################################
+#################################################################
+# Evidence-grounded operations Dashboard (ADR 0206).
+#
+# These terms project the normalized operations_case_* tables. They are
+# ontology navigation over cited facts, not Event Lineage edges or permission
+# to infer a case from text locally.
+#################################################################
+
+:OperationsCase a owl:Class ;
+ rdfs:subClassOf prov:Entity ;
+ rdfs:label "Operations case"@en .
+
+:ClaimInvestigation a owl:Class ;
+ rdfs:subClassOf :OperationsCase ;
+ rdfs:label "Claim investigation"@en .
+
+:RebidHandover a owl:Class ;
+ rdfs:subClassOf :OperationsCase ;
+ rdfs:label "Rebid or handover"@en .
+
+:ExternalInformation a owl:Class ;
+ rdfs:subClassOf :OperationsCase ;
+ rdfs:label "External information"@en .
+
+:RepeatIssue a owl:Class ;
+ rdfs:subClassOf :OperationsCase ;
+ rdfs:label "Repeat issue"@en .
+
+:OperationsCaseFact a owl:Class ;
+ rdfs:subClassOf prov:Entity ;
+ rdfs:label "Operations case fact"@en ;
+ rdfs:comment "One schema-validated fact retaining its exact authorized source Post through prov:wasDerivedFrom."@en .
+
+:hasOperationsFact a owl:ObjectProperty ;
+ rdfs:domain :OperationsCase ;
+ rdfs:range :OperationsCaseFact ;
+ rdfs:label "has operations fact"@en .
+
+:factTypeCode a owl:DatatypeProperty ;
+ rdfs:domain :OperationsCaseFact ;
+ rdfs:range xsd:string .
+
+:factValue a owl:DatatypeProperty ;
+ rdfs:domain :OperationsCaseFact ;
+ rdfs:range xsd:string .
+
+:Order a owl:Class ;
+ rdfs:label "Order"@en .
+
+:SalesContext a owl:Class ;
+ rdfs:label "Sales context"@en .
+
+:BusinessManagementContext a owl:Class ;
+ rdfs:label "Business management context"@en .
+
+:relatesToOrder a owl:ObjectProperty ;
+ rdfs:domain :ExternalInformation ;
+ rdfs:range :Order .
+
+:relatesToProject a owl:ObjectProperty ;
+ rdfs:domain :ExternalInformation ;
+ rdfs:range :Project .
+
+:relatesToSales a owl:ObjectProperty ;
+ rdfs:domain :ExternalInformation ;
+ rdfs:range :SalesContext .
+
+:relatesToBusinessManagement a owl:ObjectProperty ;
+ rdfs:domain :ExternalInformation ;
+ rdfs:range :BusinessManagementContext .
+
+#################################################################
+# Evidence-bound product semantic catalog (ADR 0228).
+#################################################################
+:Product a owl:Class ;
+ rdfs:label "Product"@en ;
+ rdfs:comment "A governed product catalog identity at group, model, variant, or trade-item level."@en .
+
+:CatalogProduct a owl:Class ;
+ rdfs:subClassOf :Product ;
+ rdfs:label "Catalog product"@en ;
+ rdfs:comment "An explicitly provisioned product identity with a stable catalog code and hierarchy level."@en .
+
+:productCatalogCode a owl:DatatypeProperty ;
+ rdfs:domain :CatalogProduct ; rdfs:range xsd:string .
+
+:preferredProductLabel a owl:DatatypeProperty ;
+ rdfs:domain :CatalogProduct ; rdfs:range xsd:string .
+
+:productLevelCode a owl:DatatypeProperty ;
+ rdfs:domain :CatalogProduct ; rdfs:range xsd:string .
+
+:parentProduct a owl:ObjectProperty ;
+ rdfs:domain :CatalogProduct ; rdfs:range :CatalogProduct .
+
+:ProductMention a owl:Class ;
+ rdfs:label "Product mention"@en ;
+ rdfs:comment "A source-span-bound product mention with a fail-closed catalog resolution outcome."@en .
+
+:mentionsProduct a owl:ObjectProperty ;
+ rdfs:domain :ProductMention ; rdfs:range :Product .
+
+:extractedProductName a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:productResolutionStatus a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:evidenceInputDigest a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:ProductRelationAssertion a owl:Class ;
+ rdfs:subClassOf rdf:Statement, prov:Entity ;
+ rdfs:label "Evidence-bound product relation"@en .
+
+:concernsProduct a owl:ObjectProperty ;
+ rdfs:domain :OperationsCaseFact ; rdfs:range :Product ;
+ rdfs:label "concerns product"@en .
+:changesProduct a owl:ObjectProperty ;
+ rdfs:domain :OperationsCaseFact ; rdfs:range :Product ;
+ rdfs:label "changes product"@en .
+:originatesFromProduct a owl:ObjectProperty ;
+ rdfs:domain :OperationsCaseFact ; rdfs:range :Product ;
+ rdfs:label "originates from product"@en .
+:sensesProduct a owl:ObjectProperty ;
+ rdfs:domain :OperationsCaseFact ; rdfs:range :Product ;
+ rdfs:label "senses product"@en ;
+ rdfs:comment "An evidence-bound external-sensing fact concerns the identified product; the relation does not arise from lexical overlap."@en .
+:usesProduct a owl:ObjectProperty ;
+ rdfs:domain :Project ; rdfs:range :Product ;
+ rdfs:label "uses product"@en .
+:productRelationEvidence a owl:DatatypeProperty ;
+ rdfs:domain :ProductRelationAssertion ; rdfs:range xsd:string .
+
+:PostVoiceClassificationAssertion a owl:Class ;
+ rdfs:label "Post voice classification assertion"@en .
+
+:OrganizationVoiceRelationshipAssertion a owl:Class ;
+ rdfs:label "Organization voice relationship assertion"@en .
+
+:voiceConceptCode a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+:voiceAssertionStatus a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+:voiceEvidenceDigest a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+:sourceRevisionDigest a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+:evidenceSpanStart a owl:DatatypeProperty ;
+ rdfs:range xsd:integer .
+:evidenceSpanEnd a owl:DatatypeProperty ;
+ rdfs:range xsd:integer .
+:validFrom a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime .
+:validTo a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime .
+:orchestratorModelReceipt a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
:WorkerFunction a owl:Class ;
rdfs:label "Worker function"@en ;
rdfs:comment "One DOT/FJA Data, People, or Things worker function: the standard terminology for how a worker functions on a job in relation to data, people, or things."@en .
diff --git a/docs/operability/compose-project-consolidation.md b/docs/operability/compose-project-consolidation.md
new file mode 100644
index 000000000..e9d47328a
--- /dev/null
+++ b/docs/operability/compose-project-consolidation.md
@@ -0,0 +1,33 @@
+# Local Compose project consolidation evidence
+
+On 2026-08-26 KST, container labels were read before any cleanup. Three
+non-canonical Compose projects were identified by exact project name and
+configuration path: `lw-cancelled-visualk5c5lp`, `lw-k6-agent`, and `lwrepro`.
+Their service definitions were compared with the Dashboard candidate. The only
+still-supported environment contract absent from that candidate was
+`TEPP_API_KEY`; it is now part of the canonical backend service.
+
+Before cleanup, `lineageweave-dashboard-metrics` ran PostgreSQL plus its
+successful one-shot migration, Valkey, SearXNG, Keycloak,
+contextual-orchestrator, backend, and frontend. Live OIDC/JWKS verification
+passed. An authenticated 2-VU, 20-second k6 run completed 162 requests with
+zero failures across posts, Event Lineage, Dashboard, and Ask polling; all
+seven observed Ask jobs in the synthetic database were `succeeded`.
+This local run left `KEYVERSE_ISSUER` unset and therefore proved the synthetic
+Keycloak fallback only. A Keyverse-configured deployment is a separate,
+fail-closed issuer and claim-binding acceptance boundary under ADR 0028/0156.
+
+Each identified Compose project was retired with its exact `-p` project name
+and `docker compose down`, without `-v`. Six named volumes remain: one
+PostgreSQL and one Valkey volume for each retired project. The independently
+created `lw-orch-hostport` container has no Compose project/configuration
+labels, so it was not guessed into a project or deleted. A later exact-label
+audit found one running `lw-k6-agent` migration container that Compose could
+not discover because it lacked configuration labels; after its project and
+service labels were revalidated, that isolated test container was removed
+directly. Stale created-only projects `lineageweave-kg-fix-20260822` and
+`lineageweave-261-exact` were also removed with their exact project names.
+Named volumes were not deleted.
+
+This is local, synthetic runtime evidence. It is neither production capacity
+evidence nor protected-main delivery evidence.
diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md
index 657bdd3bf..5bac9a66d 100644
--- a/docs/operability/http-concurrency-evidence.md
+++ b/docs/operability/http-concurrency-evidence.md
@@ -4,7 +4,7 @@ LineageWeave provides `scripts/k6_http_e2e.js` to measure the real Compose
HTTP boundary while a synthetic Global Ask job is queued or running. It logs
in through the seeded Keycloak realm, submits one non-identifying question to
`POST /api/ask`, then drives concurrent authenticated requests to posts,
-Event Lineage, and the Ask-status projection.
+Event Lineage, the evidence Dashboard, and the Ask-status projection.
This implements the measurement side of ADR 0204's resource-release decision:
provider work is asynchronous, so ordinary readers should remain observable
@@ -40,7 +40,7 @@ k6 reports observed request counts, failure rate, and duration distributions.
The custom metrics separate:
- `lineageweave_ask_enqueue_duration`: time to persist and acknowledge the job;
-- `lineageweave_read_duration{endpoint:posts|lineage}`: ordinary reader paths;
+- `lineageweave_read_duration{endpoint:posts|lineage|dashboard}`: ordinary reader paths;
- `lineageweave_ask_poll_duration`: owner-scoped status polling.
- `lineageweave_ask_state_observations{job_status:...}`: how many observations
occurred while the one queued job was queued, running, or settled.
@@ -61,23 +61,36 @@ or shared-runner result to a product guarantee.
Figma and screenshot review do not apply: this is a non-UI HTTP load harness.
-## Exact-head synthetic verification record
-
-On 2026-08-26, an isolated Compose stack built from PR #663 commit
-`be361f10` completed an authenticated 4-VU, 30-second run against 27 synthetic
-`source_post` rows. The run completed 5,537 iterations and 16,613 HTTP
-requests; all 16,611 endpoint checks passed and k6 recorded no HTTP failures.
-Ask enqueue averaged 11.88 ms. Ask polling averaged 13.61 ms, with 21.46 ms
-p95 and 156.69 ms maximum. The combined post/lineage reader metric averaged
-19.57 ms, with 31.39 ms p95 and 198.64 ms maximum. Overall HTTP duration
-averaged 17.59 ms with 29.25 ms p95, at 183.44 iterations and 550.39 requests
-per second.
-
-The host exposed 10 logical CPUs and 32 GiB RAM; Compose imposed no explicit
-backend CPU or memory limit. This exact-head observation verifies concurrent
-responsiveness for the small synthetic fixture and the asynchronous Ask
-enqueue/poll path. It does not represent authorized production volume,
-establish capacity, isolate a causal bottleneck, or establish an SLO.
+## Dashboard candidate verification record
+
+On 2026-08-26 KST (2026-08-25 UTC), the synthetic 27-post Compose dataset at candidate head
+`b045a6e5` ran with 4 VUs for 30 seconds on alternate local ports. It completed
+1,240 iterations and 4,962 authenticated HTTP requests with zero failed
+requests and 4,960/4,960 successful checks across posts, Event Lineage,
+Dashboard, and Ask polling. Overall request duration was 75.02 ms average,
+56.73 ms median, 181.76 ms p95, and 791.89 ms maximum; the combined reader
+metric was 81.68 ms average and 197.25 ms p95. The one Ask enqueue took
+173.66 ms, while Ask polling averaged 54.80 ms with 132.98 ms p95.
+
+The first candidate run exposed two Dashboard-only SQL contract defects:
+an evidence-post predicate in the missing-fact query despite that query having
+no evidence-post join, and a fifth bind value passed to the four-parameter
+topic projection. Both failed every Dashboard request while sibling endpoints
+remained responsive. The shared query boundary was repaired and regression
+tests now assert the join and bind arity; the distribution above is the clean
+rerun. This is synthetic candidate evidence, not protected-main evidence or a
+capacity/SLO claim.
+
+After the normalized topic-coordinate/provenance and lifecycle constraints were
+added, candidate `7e63d8c2` replayed migrations through `0216` on the retained
+synthetic volume and passed the real-PostgreSQL Dashboard contract. Its clean
+4-VU/30-second rerun completed 345 iterations, 1,382 requests, and 1,380/1,380
+checks with zero request failures. HTTP duration was 255.55 ms average,
+187.54 ms median, and 593.53 ms p95; the reader metric was 275.26 ms average
+and 637.94 ms p95. Ask enqueue took 791.12 ms and polling p95 was 425.66 ms.
+The host was still completing the Keycloak/Quarkus cold start immediately
+before this run, so the distribution is retained as correctness/concurrency
+evidence and is not compared as a performance regression or SLO.
## Current-main verification record
@@ -196,3 +209,14 @@ duplicate filter-option query; they do not demonstrate current-head latency,
causality, capacity, or an SLO. ADR 0212 combines the two option projections
into one database query; its physical plan remains to be measured exact-head.
Repeat the synthetic k6 run on an exact-head image before comparing effects.
+
+## Operations Dashboard exact-head observation
+
+On 2026-08-26 KST, candidate `361641ec` ran from a freshly built, isolated
+Compose project with the repository's 27-post synthetic dataset, 4 VUs, and a
+30-second observation window. It completed 974 iterations and 3,898 HTTP
+requests with zero failed requests and 3,896/3,896 successful reader checks.
+HTTP p95 was 194.70 ms; ordinary-reader p95 was 204.06 ms; Ask enqueue was
+104.90 ms. This local synthetic observation is not a capacity guarantee or an
+approved SLO; repeat it on the protected merge SHA and representative declared
+deployment capacity.
diff --git a/docs/operability/postgresql-observed-tuning.md b/docs/operability/postgresql-observed-tuning.md
new file mode 100644
index 000000000..63ddc4f41
--- /dev/null
+++ b/docs/operability/postgresql-observed-tuning.md
@@ -0,0 +1,77 @@
+# PostgreSQL observed tuning procedure
+
+This procedure produces a plan before it changes a service. Run it only after
+the canonical migration and other controlled database work have completed.
+The observation duration is required rather than defaulted: select a window
+that contains the workload being tuned and record that choice with the plan.
+
+```bash
+uv run python scripts/plan_postgres_tuning.py plan \
+ --sample-seconds "$OBSERVATION_SECONDS" \
+ --output /tmp/lineageweave-postgres-tuning-plan.json
+
+uv run python scripts/plan_postgres_tuning.py validate \
+ --plan /tmp/lineageweave-postgres-tuning-plan.json \
+ --env-output /tmp/lineageweave-postgres-tuning.env
+```
+
+Review the JSON evidence, proposed settings, exact disk reservation, retained
+settings, and rollback values. Validation renders the Compose configuration but
+does not touch a container.
+
+Apply only in an approved restart window. Copy the printed `plan_id` exactly;
+the procedure rejects a changed plan or a different approval value. Immediately
+before recreation it also re-reads the PostgreSQL major version, WAL,
+durability and isolation settings, active-transaction and waiting-lock totals,
+cgroup memory limit, data-filesystem free bytes, and current `pg_wal` bytes.
+Any mismatch, non-zero transaction/lock total, or insufficient current resource
+measurement aborts without restarting PostgreSQL. Keep the maintenance window
+closed to new work after that final snapshot.
+
+```bash
+uv run python scripts/plan_postgres_tuning.py apply \
+ --plan /tmp/lineageweave-postgres-tuning-plan.json \
+ --env-output /tmp/lineageweave-postgres-tuning.env \
+ --approve-plan-id "$APPROVED_PLAN_ID"
+```
+
+After PostgreSQL becomes healthy, compare `SHOW max_wal_size`,
+`SHOW wal_buffers`, all three durability settings, `pg_stat_wal`, and
+checkpoint counters with the plan. Do not attribute the CPU time of an active
+GIN scan to WAL or storage concurrency when its sampled WAL delta is zero.
+
+Rollback uses the plan's captured pre-change values and the same controlled
+restart gate:
+
+```bash
+uv run python scripts/plan_postgres_tuning.py rollback \
+ --plan /tmp/lineageweave-postgres-tuning-plan.json \
+ --env-output /tmp/lineageweave-postgres-rollback.env \
+ --approve-plan-id "$APPROVED_PLAN_ID"
+```
+
+The base `docker-compose.yml` contains no tuned command. Removing the tuning
+overlay and recreating PostgreSQL is the secondary rollback path.
+
+## Non-identifying canonical observation — 2026-08-27
+
+Since the 2026-08-24 statistics reset, the canonical PostgreSQL 16 instance
+reported 25,308 requested checkpoints versus 382 timed checkpoints, 336.7 GB
+of WAL, 7,598,680 `wal_buffers_full` events, 81,194,401 backend buffer writes,
+and no lock waiter at capture. The running configuration retained
+`wal_level=replica`, `max_wal_size=1GB`, and `shared_buffers=128MB` under read
+committed isolation.
+
+This snapshot confirms severe cumulative pressure, not an apply value.
+PostgreSQL documents that `max_wal_size` pressure can start a checkpoint before
+`checkpoint_timeout`, that high WAL output can require more WAL buffers, and
+that its own WAL recycling estimate adapts to prior checkpoint cycles. Run the
+aligned planner across the representative write workload before applying its
+segment-aligned proposal. The snapshot supplies no evidence for changing
+`shared_buffers`, durability, isolation, or storage concurrency.
+
+The canonical capture did not expose `pg_stat_statements`, so historical
+per-query aggregates remain unverified. Do not install or preload the extension
+as part of WAL tuning: that requires its own restart approval and a decision for
+query-text retention. Use the repository's bounded, rollback-only `EXPLAIN`
+procedure when a named operation needs plan evidence.
diff --git a/docs/operability/worker-memory-evidence.md b/docs/operability/worker-memory-evidence.md
new file mode 100644
index 000000000..1a65cb1b4
--- /dev/null
+++ b/docs/operability/worker-memory-evidence.md
@@ -0,0 +1,23 @@
+# Worker memory evidence procedure
+
+Run this before restarting or recreating a worker, over a declared window that
+contains the workload and concurrency being accepted:
+
+```bash
+uv run python scripts/capture_worker_memory_evidence.py \
+ --sample-seconds "$OBSERVATION_SECONDS" \
+ --output /tmp/lineageweave-worker-memory-evidence.json
+```
+
+The output contains aggregates and no container identifier or record content.
+Preserve it outside git with the workload definition and host capacity. An
+`oom_confirmed` result requires Docker `OOMKilled` or a local kernel
+`oom_kill` delta. `sigkill_unattributed` requires further host/runtime logs;
+do not relabel it OOM. A container change invalidates the window. If the same
+container exits, ending cgroup values remain unavailable and the retained
+pre-exit peak is labeled as such; it is not a whole-window maximum.
+
+Acceptance requires the declared representative workload to finish on one
+unchanged container with zero `high`, `max`, `oom`, and `oom_kill` deltas.
+The observed peak is evidence, not a proposed Compose limit. Any future
+`mem_limit`/`mem_reservation` change needs a separate ADR and rollback test.
diff --git a/docs/product-requirements.md b/docs/product-requirements.md
index a8b741521..1e432e5c2 100644
--- a/docs/product-requirements.md
+++ b/docs/product-requirements.md
@@ -91,6 +91,10 @@ continues to pass unchanged.
(ADR 0248). FJA worker functions remain separate.
- Reuse official external identifiers and source-published relationships;
never infer a DPT-to-psychology crosswalk or relabel work style as affect.
+- Publish the eight O*NET 31.0 Ability, Essential Skill, Transferable Skill,
+ and Work Style link tables to Work Activities and Work Context as 1,417
+ directed, assertion-level provenance-bearing relations (ADR 0256). Treat
+ relevance as neither a causal effect nor a numeric weight.
- Bind a construct to record content only through a provenance-bearing,
evidence-cited assertion. Do not promote record evidence to a person trait,
score, causal effect, or job requirement.
@@ -111,7 +115,8 @@ projects assertion-backed constructs into the existing ABAC-filtered ontology
neighborhood without duplicating graph storage or promoting truth. ADR 0257
adds authorized catalog-label search: reviewers type an official O*NET label
and open the earliest visible supporting Post. Constructs without visible
-evidence stay undisclosed. Occupation ratings remain unavailable.
+evidence stay undisclosed. Ontology tests reproduce every pinned O*NET linkage
+with its exact source table. Occupation ratings remain unavailable.
### PRD-FR-2C — FJA I/O-Psychology cognitive, affective & behavioral semantic layer
@@ -136,52 +141,7 @@ literature-anchored metadata, fail-closed lookups, per-function profile
completeness, and composite-job aggregation; `tests/test_ontology_shapes.py`
validates the disjoint SHACL shapes.
-
-
-### PRD-FR-2B-2 — Occupational classification and worker-characteristic taxonomy
-
-- Publish the 23 major groups of the 2018 Standard Occupational
- Classification (the O*NET job-family grouping) with official titles
- and codes verbatim, plus the four O*NET 31.0 job-zone categories with
- published names and source values 2 through 5 (ADR 0245).
-- Publish the worker-characteristic families that work-related
- cognition, affect, and behavior resolve into: Fleishman's four ability
- domains, Holland's six RIASEC interest types with the published
- hexagonal adjacency relation, the six explicitly legacy O*NET work-value
- clusters, and
- the seven higher-order dimensions of the revised O*NET Work Styles
- structure.
-- Declare typed derivation properties from classifications to
- characteristics but assert no instance binding; binding requires a
- versioned released source profile imported with provenance in its own
- decision.
-- Expose everything through a deterministic application read model with
- fail-closed lookups; carry no numeric importance or level rating from
- any occupational profile.
-
-Acceptance: completeness counts, verbatim titles, closed RIASEC
-vocabulary, exact published adjacency pairs, deterministic ordering,
-canonical namespace, and lookup round-trip isolation are enforced by
-`tests/test_io_taxonomy.py`; `tests/test_ontology.py` continues to pass
-unchanged.
-
-### PRD-FR-2A — Worker-function taxonomy
-
-- Publish the DOT/FJA Data/People/Things worker functions (24 concepts,
- official definitions verbatim) in the canonical ontology namespace
- (ADR 0232), each with its definitional ordinal rank. Do not infer a
- DOT-to-O*NET or Fleishman crosswalk that the authorities do not publish.
-- Expose the taxonomy through a deterministic application read model with
- fail-closed lookups; an absent function is an honest unknown.
-- Carry no numeric weight from the taxonomy: ranks are scale positions,
- never calibrated weights.
-
-Acceptance: completeness, full verbatim definitions, deterministic ordering,
-and lookup round-trip isolation are enforced by
-`tests/test_worker_function_taxonomy.py`; `tests/test_ontology.py`
-continues to pass unchanged.
-
-### PRD-FR-2B — Occupational classification and worker-characteristic taxonomy
+### PRD-FR-2D — Occupational classification and worker-characteristic taxonomy
- Publish all four levels of the 2018 Standard Occupational Classification:
23 major groups, 98 minor groups, 459 broad occupations, and 867 detailed
@@ -213,27 +173,8 @@ canonical namespace, and lookup round-trip isolation are enforced by
`tests/test_io_taxonomy.py`, `tests/test_soc_2018_hierarchy.py`, and
`tests/test_onet_content_model.py`;
`tests/test_ontology.py` continues to pass unchanged.
-### PRD-FR-2C — Evidence-bound occupational constructs
-- Keep cognitive abilities, work styles, work activities, affective
- reactions, and performance behaviors as non-equivalent construct classes
- (ADR 0248). FJA worker functions remain separate.
-- Reuse official external identifiers and source-published relationships;
- never infer a DPT-to-psychology crosswalk or relabel work style as affect.
-- Publish the eight O*NET 31.0 Ability, Essential Skill, Transferable Skill,
- and Work Style link tables to Work Activities and Work Context as 1,417
- directed, assertion-level provenance-bearing relations (ADR 0256). Treat
- relevance as neither a causal effect nor a numeric weight.
-- Bind a construct to record content only through a provenance-bearing,
- evidence-cited assertion. Do not promote record evidence to a person trait,
- score, causal effect, or job requirement.
-
-Acceptance: SHACL rejects incomplete record assertions; ontology tests
-prohibit FJA equivalence, require exact Post/evidence/PROV statement structure,
-and reproduce every pinned O*NET linkage with its exact source table. Runtime
-persistence and UI remain unavailable until their separate ADR acceptance.
-
-### PRD-FR-2D — Occupation-rating source observations
+### PRD-FR-2E — Occupation-rating source observations
- Persist released occupation-to-element ratings as source observations, not
ontology weights: release, source table, occupation, element, scale,
@@ -257,7 +198,7 @@ repeated null-category UPSERT is idempotent.
API, UI, and derived modeling remain unavailable until separate accepted
delivery records.
-### PRD-FR-2E — Occupation-rating evidence read
+### PRD-FR-2F — Occupation-rating evidence read
- Let an authenticated user open one exact release/source/occupation profile
with both rating and scale artifact provenance (ADR 0258).
@@ -270,7 +211,7 @@ Acceptance: invalid identifiers and unbounded pages are rejected; an unavailable
source never appears as a negative profile; pagination is deterministic; and a
suppressed observation retains its value and warning flag together.
-### PRD-FR-2F — Occupation-rating evidence view
+### PRD-FR-2G — Occupation-rating evidence view
- Let an authenticated user submit an exact O*NET-SOC code, release, and source
from the existing Dashboard without changing the governed GNB (ADR 0259).
@@ -284,7 +225,7 @@ scrollable table; narrow layouts retain complete values; suppression remains
visible beside its value; and Storybook covers populated, narrow, unavailable,
and empty states using synthetic data.
-### PRD-FR-2G — Imported rating-source catalog
+### PRD-FR-2H — Imported rating-source catalog
- Populate the occupation evidence selector only from imported artifacts that
contain observations, preserving release and artifact provenance (ADR 0260).
@@ -297,7 +238,7 @@ order follows persisted import time rather than parsed version heuristics; and
the real PostgreSQL integration test proves an imported synthetic artifact is
listed while its supporting scale artifact is not.
-### PRD-FR-2H — Occupations represented in a rating source
+### PRD-FR-2I — Occupations represented in a rating source
- Populate the occupation selector with exact stored code/title pairs that
have observations in the selected imported source (ADR 0261).
@@ -312,7 +253,7 @@ the PostgreSQL integration test proves the source membership predicate; and
component tests prove selector changes clear prior evidence and pagination
stays bound to the loaded profile identifiers.
-### PRD-FR-2I — Occupation catalog title filter
+### PRD-FR-2J — Occupation catalog title filter
- Let an authenticated user filter the imported occupation catalog by
published title or retained code without ranking or typed-code fallback
@@ -324,7 +265,7 @@ stays bound to the loaded profile identifiers.
Acceptance: submitting still sends only a catalog identity; a non-matching
filter never creates a request; and Storybook covers a no-match state.
-### PRD-FR-2J — Authorized job-family and job-series snapshots
+### PRD-FR-2K — Authorized job-family and job-series snapshots
- Import one authorized, pinned organization-specific source snapshot without
committing runtime rows or creating an organization (ADR 0263).
@@ -357,6 +298,9 @@ dangling endpoints fail closed; fixed input produces stable page boundaries.
- Preserve source representation and derive ordered paragraph, list, table,
formula, conversation-turn, and image-region semantic units.
- Route embeddings, LLM, and VISION through contextual-orchestrator.
+- Let an authorized administrator enqueue only a bounded page of eligible,
+ incomplete posts into the durable worker ledger; acknowledge before model
+ work and recover a missing broker wake-up from PostgreSQL.
- Apply authorization/time/process scope before ranking and again before
response delivery.
- Keep internal post citations separate from external public citations.
@@ -383,8 +327,9 @@ stale evidence from a previously opened post.
### PRD-FR-5A — Opt-in public claim verification
- Persist an explicit per-question opt-in before any external search begins.
-- Nominate only cited, public semantic/KG facts; source bodies, private facts,
- personal facts, and measurement outputs never become external queries.
+- Admit only persisted, provenance-bearing claims for exact cited public posts;
+ source bodies, private facts, personal facts, measurement outputs, and claims
+ nominated from question-token overlap never become external queries (ADR 0275).
- Retrieve bounded public evidence through SearXNG and adjudicate through
contextual-orchestrator's verification mode.
- Report supported, refuted, and not-enough-information outcomes without
@@ -395,6 +340,8 @@ stale evidence from a previously opened post.
Acceptance: leaving the control off causes no public request; hidden or
uncited facts cause no public request; unavailable services fail closed; and
each displayed public judgment retains its originating internal evidence IDs.
+An absent or unauthorized persisted envelope performs no external request and
+reports that no public claim is available rather than fabricating admission.
### PRD-FR-5B — Knowledge-cutoff Global Ask
@@ -425,6 +372,80 @@ Acceptance: MCP and REST produce the same scope snapshot, verification opt-in,
knowledge cutoff, status, citations, and limitations; cross-account reads are
404-equivalent; and exhaustion returns the bounded actual retry interval.
+### PRD-FR-5D — Ask citation and event navigation
+
+- Link each numbered Ask citation to one authorized event card and preserve the
+ same number when cards are ordered by observed time.
+- Move focus citation-to-card and card-to-citation, then open the existing
+ evidence layer or full source post.
+- Name `event_occurred_at` or the `created_at` fallback; never turn chronology
+ into a project start, predecessor, branch, or recommended response.
+
+Acceptance: keyboard selection works in both directions, every card opens its
+authorized source, missing time stays explicit, and any commercial next action
+comes from the cited answer rather than frontend inference.
+
+### PRD-FR-5E — Evidence-backed operations Dashboard
+
+- For claim investigation, show the occurrence order, specification change,
+ originating order, and sales-pool value only from authorized source spans.
+- For rebid and handover, show the discussion, counterparties, our owner, and
+ subsequent decision only from authorized source spans.
+- Count external-information posts and events, report their share of all
+ eligible posts in the selected period, and link their order, project, sales,
+ and business relations to the exact supporting post.
+- Persist closed-vocabulary milestones for claim, rebid, and handover. Report
+ open, resolved, and evidence-missing counts and elapsed time only between two
+ observed endpoints; never invent an endpoint or delay threshold.
+- Count Events per work type only from those cited, normalized milestones.
+ Never copy a Post's general summary Events into each case classification;
+ a case with no supported milestone reports zero Events.
+- Keep exact all-period headline and lifecycle counts while returning detailed
+ case evidence through stable keyset continuation; loading another page must
+ not repeat, omit, or recalculate an already displayed case.
+- Present project-specific journeys only from accepted evidence-bearing
+ predecessor and branch relations. A timestamp sort may be labeled observed
+ events, but never promoted to a journey.
+- Attach digest-bound interval-consistency evidence only to an already
+ admitted predecessor edge. Temporal order alone never creates a predecessor,
+ branch, responsibility handoff, or causal transition (ADR 0276).
+
+Acceptance: every populated fact, lifecycle endpoint, membership, and journey
+event opens an authorized evidence post; an incomplete provenance chain fails
+closed instead of returning a partial fitted result. Dashboard detail pages are
+bounded and continuable while their summary counts remain exact.
+
+### PRD-FR-5F — Product and Voice semantic evidence
+
+An authorized catalog manager shall be able to add an explicit product-master
+row through a governed API. The request shall include the product code,
+preferred label, hierarchy level, optional existing parent, authorized source
+system and record, corporate-entity scope, and explicit aliases. The product
+shall retain a server-calculated payload digest and alias-level source links;
+identical replay shall be idempotent and contradictory replay shall fail
+closed. Extracted mentions continue to resolve only as unique, missing, tied,
+or unavailable. Neither a model, keyword, fuzzy match, nor a source `기타`
+value may create or revise catalog identity.
+
+- Extract product mentions through contextual-orchestrator from authorized
+ semantic units and resolve only against normalized product group, model,
+ variant, and trade-item identities with scoped GTIN or MPN identifiers.
+- Keep unique, tied, missing, unavailable, processing, and successfully empty
+ outcomes distinct; source changes invalidate derived analysis and failures
+ remain durable and retryable.
+- Link product relations to projects and operational facts through authorized
+ evidence posts, and suppress live product inference in a historical view
+ until a cutoff-bound product contract exists.
+- Keep source-post Voice categories (`voc`, `vocc`, `voco`, `vom`, `vop`,
+ `vos`, `voe`, `vob`, `vor`, `voi`, `voso`, `vops`) separate from
+ organization relationship categories. Preserve source and derived
+ multi-membership and disclose overlaps and disagreement without forced
+ selection.
+
+Acceptance: zero products is shown only after a completed current-input
+analysis; absent or failed analysis provides the next valid action, and every
+displayed product or Voice assertion retains navigable authorized provenance.
+
### PRD-FR-6 — Measurement boundary
- Consume TEPP accepted/completed wire contracts and fast-mlsirm outputs; do
@@ -466,6 +487,10 @@ vendor selector, duplicate identity store, or psychometric substitute appears.
envelope rather than an unmeasured concurrency claim.
- Public APIs have bounded inputs, stable typed responses, and provenance-
preserving failure states.
+- Every authenticated REST `GET` and MCP read completes service-side response
+ production within 20 ms under the declared exact-head deployment and
+ workload. Cold and warm maximums are both release gates; timeouts, partial
+ responses, cache-hit-only evidence, and averages do not satisfy ADR 0272.
- WCAG 2.2 AA, keyboard/touch parity, responsive layouts, reduced motion,
design tokens, Storybook edge states, and screenshot review apply to every
customer-facing surface.
@@ -504,8 +529,10 @@ A release claim requires one exact protected-main head that proves:
- Asynchronous delivery and database-pool isolation: ADR 0204, ADR 0213.
- Knowledge Graph, ontology, and provenance: ADR 0004, ADR 0011, ADR 0065,
ADR 0184, ADR 0207, ADR 0222, ADR 0246, ADR 0256.
- ADR 0184, ADR 0207, ADR 0222, ADR 0246.
-- Semantic units and retrieval: ADR 0047, ADR 0062, ADR 0102, ADR 0217.
+- Semantic units and retrieval: ADR 0047, ADR 0062, ADR 0098, ADR 0102,
+ ADR 0217.
+- Evidence operations, products, and Voice: ADR 0206, ADR 0210, ADR 0225,
+ ADR 0228, ADR 0244, ADR 0246.
- LLM/model boundary: ADR 0070, ADR 0072, ADR 0076, ADR 0079.
- Measurement: ADR 0003, ADR 0145, ADR 0200, ADR 0205.
- UX and publication: ADR 0118, ADR 0159.
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index b5d31877b..571397829 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -1,5 +1,131 @@
# Product & Technical Gap Baseline
+> Current exact-head overlay: 2026-09-01 KST. Protected `main` is
+> `cb187cadee5fb6c46d8a944815ccc154a1e028d1`; PR #640 evidence is current
+> through head `51ab4616b80ebb9fc5c3a6a596b79c209226e745`. The PR is
+> blocked, still requires review, and has required checks queued; auto-merge is
+> off. The repository has 98 open
+> PRs and 11 open issues. This current overlay supersedes the older dated
+> queue snapshots below; neither branch tests nor the running Compose stack
+> are protected-main delivery evidence.
+>
+> Exact-head buyer acceptance: authenticated desktop and 390-pixel mobile
+> Dashboard browser tests passed against the canonical Compose stack, including
+> grounded-case evidence opening, responsive navigation, Korean/English
+> switching, and zero browser, request, or HTTP errors on the Dashboard and idle
+> Ask surface. The bounded 100-record page is terminal with no queued or running
+> rows and no later page was admitted; failures remain retryable only after their
+> typed root causes are repaired. contextual-orchestrator PR #970 is at
+> `3f6fce20ec1a0475f36f775786979b9b7d808ecf`; required checks and
+> independent review remain pending. Stacked upstream PR #990 merged normally
+> into that base; its exact source head
+> `c25712646bb25d0d30e4a5146ca9ea54669dfdf6` adds exact request-scoped endpoint
+> routing, the OpenAI JSON probe contract, and single-copy caller prompt
+> preservation. Its isolated LineageWeave candidate
+> returned conforming Chat Completions and Responses objects through the configured
+> endpoint. The isolated administrator readiness gate passed and canonical Compose
+> now runs that exact revision; post-scoped Responses correlation also passed through
+> OpenAI-compatible metadata without an unknown top-level field. This is runtime
+> and stacked-base evidence, while protected-main upstream delivery remains pending.
+> Org workflow PR #1507 merged normally; central Pingora policy PR #1466 is at
+> `7ffc89d7f76f26535602ffd29dcb19f4af00a50f`, mergeable, and awaits fresh
+> checks plus replacement of its stale changes-requested review.
+> Terminal retry and unbounded continuation remain mutually exclusive, so each
+> admitted page must settle before another page is selected.
+>
+> Current Dashboard performance evidence separates selected-path improvement
+> from the unchanged all-read release gate. At exact image head `c47cabed2`,
+> the authenticated response negotiated `Content-Encoding: gzip`; the complete
+> payload measured 96,415 bytes without compression and 10,282 bytes on the
+> wire. A five-VU, ten-second k6 observation accepted all 4,131 responses and
+> measured 11.43 ms average, 10.38 ms median, 17.50 ms p95, and 130.43 ms
+> maximum. The negotiated path therefore improved transfer volume and
+> throughput but still fails the 20 ms maximum; neither Dashboard nor all-read
+> acceptance is claimed.
+> Exact-head desktop and 390-pixel mobile screenshots were retained outside
+> git and reviewed without exposing implementation terminology. After applying
+> the shared 44-pixel touch-target
+> token, every visible Dashboard/global control met that minimum; desktop and
+> mobile authenticated browser flows and the production frontend build passed.
+> The populated Dashboard Storybook scene now exposes cited actions for all eight
+> required claim and rebid/handover questions; its missing-fact scene independently
+> proves fail-closed Sales-pool guidance. The frontend suite passed 605 tests and the
+> Storybook production build completed. Authenticated non-empty Ask citations,
+> grounded runtime detail cards, authoritative journey reconciliation, and a manual
+> screen-reader audit remain open acceptance evidence. Global Ask now has an
+> independent durable consumer at this exact head. The exact-revision Ask worker
+> reached healthy while the broad post-content worker stayed stopped, settled the
+> one previously queued Ask job to succeeded, and persisted a non-empty answer with
+> one cited Post and one cited Event. No identifying question, answer, citation id,
+> or source record was retained in this baseline. Two pre-existing post-content
+> stream entries remained unconsumed, proving that Ask availability did not restart
+> or drain the stopped backfill path.
+>
+> PostgreSQL's measured state retains `read committed` isolation with `fsync`,
+> full-page writes, and synchronous commit enabled. The evidence-bound tuning
+> planner observed no WAL writes, requested checkpoints, waiting locks, or
+> active transactions during its bounded sample. Its validated plan retained
+> the 1 GiB WAL ceiling and proposed only the measured 4 MiB-to-16 MiB WAL
+> buffer change. That plan was not applied because a zero-write sample is not
+> sufficient deployment evidence; controlled apply remains plan-ID-gated and
+> revalidates live isolation, durability, space, locks, and transaction state.
+>
+> PR #640 declares and SHACL-validates the operations-case JSON-LD
+> vocabulary that its Dashboard emits, keeps the packaged fallback graph-isomorphic
+> with the authoritative Turtle, and includes that fallback in built wheels. This is
+> implemented branch evidence; protected-main ontology publication and authenticated
+> installed-runtime acceptance remain unverified. No additional ontology class is
+> missing for the current normalized case, fact, relation, and milestone-code
+> projection. No screenshot or identifying runtime record is committed. The ontology
+> current exact head passed 2,351 repository tests with 16 integration skips;
+> those branch tests are not protected-main proof. Canonical Compose applied the
+> replay-safe 0271/0272 schema and serves the new Voice/Product read contracts from
+> exact PR #640 backend/frontend images. The broad worker remains stopped on its
+> earlier image, while the consumer-selective Ask-only worker runs exact head
+> `51ab4616b80ebb9fc5c3a6a596b79c209226e745`; therefore neither receipt-bearing
+> Voice/Product producer is deployed. Derived Voice completions and active
+> derived assertions are both zero. Historical Product analysis rows have no model
+> receipt and expose zero Product evidence; they are retry candidates, not completed
+> analyses or corpus prevalence. Worker replacement remains gated on stacked PR #895,
+> whose process-epoch probe fix is open with required checks queued.
+>
+> Canonical project binding retains three normalized membership rows on one Post.
+> Separately, the current Dashboard rollup has 24 project-bearing case-kind rows across
+> 22 Posts, all from explicit source project fields; the earlier 23/21 rollup snapshot
+> was not a normalized-membership count. An authenticated
+> Dashboard page included 2 project-bound cases among 20 returned cases, and an
+> exact-code project journey returned one matching event; desktop and mobile
+> browser acceptance rendered the project groups without committing an identifier.
+> PR #896 merged normally into #640's non-default branch as squash
+> `1788082e5672232118443eda31b5dbe34c4947bf`. Its in-progress hosted backend and
+> frontend jobs were cancelled by that merge and are not green evidence; fresh
+> integrated #640 checks are queued. #896's aggregate runtime evidence proves only
+> the selected Dashboard-to-Project-History path, not the all-read 20 ms contract or
+> #888 activation. Protected-main delivery remains unverified. The earlier Global Ask
+> semantic-retrieval gap is closed in the canonical candidate runtime: the pinned
+> orchestrator accepts the configured endpoint contract, and the isolated Ask worker
+> persisted a non-empty answer with one Post citation and one Event citation while
+> the broad worker remained stopped. Protected-main upstream and LineageWeave
+> delivery, source-reference attachment for that answer, and wider authorized-corpus
+> acceptance remain unverified.
+
+> Current rebuild overlay: 2026-08-28 KST. Protected `main` is
+> `ff7431bd1851c03e737808d22c6a2d43968582f9`; PR #640 is a ready-for-review
+> current-main semantic rebuild at `f0bc98eef238b7a03d4227ab909c8de296041f36`.
+> PR #778 is remotely published at
+> `b87b186dd7213dc59d8e933e7d8c3f330598470f`; PR #781's last remote
+> exact-head evidence before this overlay is
+> `760d05896f96e5ce7fb9df0e4b62369448913fbd` and remains candidate-only.
+> Its contextual-orchestrator runtime is pinned to open upstream replacement PR #970 exact
+> `e6329db1b9d0fb59b23cf63b4e4b056743b8a5da`; this candidate is not
+> protected-main evidence.
+> The open queue has 14 PRs:
+> #783, #782, #781, #780, #778, #774, #772, #771, #770, #702, #679, #672,
+> #667, and #640; #702/#679/#672/#667 remain drafts. Local candidate tests do
+> not transfer to the remote PR head or protected `main`. Exact-head Compose,
+> browser, load, and backfill acceptance remains pending. This
+> overlay supersedes every older queue count below while the dated historical
+> snapshots remain supporting evidence only.
> Exact-head loop overlay: 2026-08-29 13:20 KST. Protected `main` is
> `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map
> explained leftover share, #775). Open ready PRs still lack independent
@@ -374,15 +500,16 @@ explicit unavailable state, not a reason to infer mappings from labels.
| Requirement | Evidence contract | Delivery state |
|---|---|---|
-| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate implementation; authenticated runtime acceptance pending |
-| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate implementation; corpus backfill pending |
+| Operations ontology runtime package | ADR 0206 governed OWL/JSON-LD projection; authoritative Turtle, SHACL, and installed fallback must agree | **Implemented on the PR branch:** the wheel declares the Turtle package data and tests graph isomorphism with the authoritative source. **Unverified:** protected-main Pages publication and authenticated installed-runtime acceptance. No speculative milestone classes are required because the normalized API contract governs milestones by closed codes. |
+| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; case-specific normalized milestones | Candidate implementation counts only cited claim milestones instead of duplicating every Post summary Event; authenticated runtime acceptance pending |
+| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts and case-specific normalized milestones | Candidate implementation counts only cited rebid/handover milestones; corpus backfill pending |
| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate implementation; no separate Board by product decision |
-| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API and ordered journey UI implemented; authenticated runtime acceptance pending |
+| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | **Implemented on the PR branch and aggregate runtime-accepted:** canonical storage retains three normalized membership rows on one Post. Separately, 24 project-bearing Dashboard case-kind rollups across 22 Posts use explicit source project fields; these are case rows, not membership rows, and may change while the bounded page settles. An authenticated 20-case page included 2 project-bound cases and one exact-code journey returned one matching event. Desktop/mobile rendering passed without retaining identifiers. **Unverified:** protected-main delivery and authoritative lifecycle/handover reconciliation. |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
-| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending |
+| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback. **Runtime-accepted on the PR head:** the exact `51ab4616` Ask-only worker became healthy while the broad worker stayed stopped, settled the one queued request, and persisted a non-empty answer with one cited Post and one cited Event. This closes the earlier configured-gateway admission and non-empty citation gap without exposing identifying records. **Open/unverified:** the latest answer attached no separate source-reference row, MCP authenticated E2E acceptance remains incomplete, and neither the upstream stack nor LineageWeave is protected-main delivery evidence. |
| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending |
| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet |
-| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | Product/technical contract is protected on `main`; neither required Rust CPU/GPU producer envelope is shipped, so the Dashboard surface remains unavailable (ADR 0208: no local Python substitute) |
+| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | The stacked successor adds a durable, short-transaction producer request, exact accepted TEPP posterior run/snapshot/cutoff/artifact binding, four-level source-membership admission, complete-result validation, and normalized persistence. It does not misbind the older topic-lineage envelope or calibrated-measurement receipt to this scientifically distinct posterior projection. Incomplete evidence is stored until a new evidence event, expired work follows an operator-declared lease that strictly exceeds the request timeout, changed input automatically produces a fresh request, and exact request, membership, and result artifact bytes are digest-verified before parsing. The Dashboard remains unavailable until TEPP publishes the full posterior/membership artifact and fast-mlsirm publishes the domain-neutral continuous-posterior Rust result endpoint; the crossed weighted MAP binary kernel is not misapplied and no local Python substitute exists |
### Technical contract and flow
@@ -456,7 +583,7 @@ context only.
| #657 | `2d9b43b7` | TEPP asynchronous lifecycle persistence while unpublished producer work stays unavailable; hosted checks and independent review required |
| #644 | `ed8d97f3` | native frontend surface code splitting; hosted checks and independent review required |
| #643 | `7fb4d18c` | shared token-backed status notice; hosted checks and independent review required |
-| #640 | `2d50fa01` | dashboard case metrics and project journeys; base conflict remains to be repaired |
+| #640 | `1788082e` | current integrated dashboard case metrics and project journeys head, including merged stack PR #896; blocked with required checks queued and independent review still required |
| #639 | `48065ad1` | restores Running action and Compose contracts; hosted checks and independent review required |
| #632 | `29aee18d` | graph-fact provenance, public verification, MCP admission, and k6 evidence; hosted checks and independent review required |
| #631 | `665046dc` (observed parent) | decomposes closed PR #490; this merge refresh advances its head and restarts hosted review evidence |
@@ -584,10 +711,18 @@ public history. Do not reproduce or hint at its value. Historical remediation
requires the ADR 0001 incident process and security/privacy-owner coordination;
never force-push or delete evidence ad hoc.
-The Grok durable hourly loop and the central thin GitHub Actions caller
-ContextualWisdomLab/.github#1259 (minute 4, `pr-review-fix-scheduler.yml`)
-both target this repository. Do not add a LineageWeave-local duplicate
-workflow. ContextualWisdomLab/.github#1258 merged at exact head `897819c4` to
+The former central caller PRs ContextualWisdomLab/.github#1259 and #1288 both
+closed without merge and therefore are not scheduler evidence. PR #1380 merged
+normally at head `9ffd7bec`: its `4 * * * *` caller performs one bounded
+OpenCode/contextual-orchestrator PR review-and-repair dispatch without
+`COPILOT_GITHUB_TOKEN`, and the two-hour unchanged-head retry prevents overlap
+with multi-hour workers. It does not discover or implement product gaps.
+LineageWeave still lacks the explicit commercial-development entrypoint marker,
+and the latest central coordinator failed closed before repository inventory
+because `PR_REVIEW_MERGE_TOKEN` was absent. Autonomous hourly product
+development therefore remains unavailable; no credential or repository opt-in
+is inferred or added here.
+ContextualWisdomLab/.github#1258 merged at exact head `897819c4` to
repair the pnpm/coverage-evidence workflow; newly created exact PR heads must
still prove the runtime behavior because merged workflow source alone is not
check evidence.
@@ -734,17 +869,21 @@ this file per §3.5 of the prior snapshot).
| Gap | Current evidence | Acceptance requirement |
| --- | --- | --- |
| Protected release | 12 open PRs at snapshot, all targeting `main` with normal auto-merge enabled. None has the required independent approval, and running checks on #631/#632/#663 are not treated as blockers for safe work on other PRs. #666's merge into the non-default #663 branch is not protected-main delivery | Terminal exact-head checks, no unresolved threads, two independent approvals including last-push approval, protected squash-merge SHA |
+| Orchestrator admission and readiness | The pinned `c2571264` orchestrator is healthy and contains the configured-endpoint contract plus the corrected JSON-object discovery probe. The exact `51ab4616` Ask worker used that boundary to persist a non-empty answer with Post/Event citations; no local provider selection or retry heuristic was added. | Complete the upstream protected gate, repin the eventual protected commit without changing the endpoint-neutral LineageWeave contract, and repeat MCP plus wider authorized-corpus acceptance. |
| CI queue release latency | Two Tests runs for already merged PRs occupied the available runner slots while 54 newer runs remained queued. Manual cancellation released the stale work, but the central close workflow was itself queued behind those runs. #634 merged into #631's non-default branch and reuses the repository's existing per-PR concurrency group so a jobless close event can cancel obsolete Tests work before runner allocation; this is not protected-main delivery | Merge #631 through its refreshed protected gate; close a synthetic PR while its Tests run is active and verify the old run becomes cancelled, the close-event jobs remain skipped, and a newer exact-head run starts without manual intervention |
| Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. Authorized-corpus backfill acceptance remains unavailable | Perform authenticated authorized-corpus acceptance with aggregate evidence and retain fail-closed no-match behavior |
| Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push |
| Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc |
| Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence |
| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work, and the synthetic Compose boundary has an authenticated k6 E2E harness for Ask enqueue, concurrent reads, and job polling. PR #633's measured landing-query and event-loop work merged into open parent #629 rather than protected `main`; its aggregate observation improved 25-VU throughput but did not establish a latency SLO. The current exact #629 also persists each completed relation verification before propagating a later provider failure | Land #629 through its refreshed protected gate, rebuild that exact-head application image, and repeat `make load-http` with declared environment concurrency/window and retained raw distributions/resource configuration; set no SLO until representative capacity evidence is approved |
+| Test broker isolation | Backend integration tests use Valkey database 15 and clear only that database at session boundaries. At the current exact-head runtime check, canonical database 0 retained two pre-existing post-content stream entries while the broad worker stayed stopped; the Ask-only worker neither consumed nor expanded that stream. | Keep runtime and test broker databases distinct; record pre/post stream length for integration acceptance and never restart or drain a production backfill as part of a test. Existing production work is not test debris and must not be deleted to manufacture an empty assertion. |
| Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing |
| Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts |
-| Event and project semantics | #663 is the largest current user-visible gap slice: evidence-backed Project nodes, bounded traversal, cutoff/snapshot fencing, exact-value table parity, and localized graph labels. Focus visibility, label-bound, and temporal test-double regressions are repaired. #666's heuristic removal is composed into this parent but is not separately protected-main evidence. #640 separately adds project journeys without claiming authoritative lifecycle status | Combined #663 must pass exact-head checks and independent approval before protected merge. Aggregate authenticated evidence must still prove distinct projects/events and handover intervals without promoting co-occurrence |
+| Event and project semantics | #663 is the largest current user-visible gap slice: evidence-backed Project nodes, bounded traversal, cutoff/snapshot fencing, exact-value table parity, and localized graph labels. Focus visibility, label-bound, and temporal test-double regressions are repaired. #666's heuristic removal is composed into this parent but is not separately protected-main evidence. #640 now has aggregate authenticated proof that explicit source project fields produce project-bearing case rollups and render one matching journey event. Three normalized membership rows exist separately; rollup rows are not relabeled as memberships, and no authoritative lifecycle status is claimed. | Combined #663 must pass exact-head checks and independent approval before protected merge. Authenticated acceptance must still prove handover intervals and authoritative lifecycle reconciliation without promoting co-occurrence. |
| Voice primary history | Protected `main` `bbb19192` includes ADR 0252 / #761 (migration 0243, GiST primary-period exclusion, `clock_timestamp()` after the source-row lock, API/ontology half-open cutoff SQL). v2.22.1 adds synthetic PostgreSQL integration tests for A → B → A at before/between/after cutoffs, concurrent primary updates, additional-assignment close, and 0237→0243 trigger replay. This is not yet protected-main evidence | Land the live-test slice through the protected gate with independent exact-head APPROVE; close #748 only after that protected delivery |
| Knowledge Graph readability | #659 recreates the token-backed node-type repair on current `main`, including regression coverage; it is open and therefore not protected-main evidence | Merge #659 normally, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface |
+| Product semantic identity | ADR 0228 and migration 0251 define normalized product group/model/variant/trade-item identities, scoped GTIN/MPN keys, and fail-closed unique/tie/missing/unavailable resolution. The governed provisioning candidate adds an add-only admin contract with explicit product code/label, authorized source-system record, server-calculated digest, and alias-level source evidence; `CatalogProductShape` validates stable code/label/level/parent projection, and unique Post results expose the same catalog id/code/IRI. It never creates identity from model output, keywords, fuzzy similarity, or `기타`. **Implemented and runtime-verified read foundation:** replay-safe migration 0272 is applied in canonical Compose, and the Post API hides every historical unreceipted analysis row; the current authorized read returned zero Product evidence. **Missing/unverified production:** the canonical worker predates the independent receipt-bearing Product stage, receipt-bearing completions remain zero, and the admitted bounded page is unsettled. Existing unreceipted rows are retry eligibility evidence, not completed analyses or corpus prevalence. Exact upstream `기타` category presence remains unavailable because no SOURCE DSN was configured in the observed runtime. Protected delivery, bounded retry settlement, aggregate relation coverage, and authenticated non-empty rendered acceptance remain unproven. | Pass #895's worker probe gate and PR #640's protected checks/review, deploy the exact worker only after the active page settles, then run a separately authorized bounded retry and retain only aggregate unique/missing/tie/unavailable and relation coverage plus authenticated desktop/mobile evidence. |
+| Voice semantic taxonomy | ADRs 0244/0246 and migrations 0230/0235 preserve the twelve-value source-post scheme separately from the six-value post-scoped organization relationship scheme, retain source/derived disagreement and multi-membership, and provide authorized overlap-aware aggregate filters. **Implemented and runtime-verified read foundation:** PR #640's replay-safe migration 0271 is applied in canonical Compose; the authenticated summary admitted 43,162 source-classified Posts and reported zero derived completions, assertions, disagreements, or unavailable rows. **Missing/unverified production:** the canonical worker predates the strict receipt-bearing Voice stage, so the producer is not deployed and no private-corpus accuracy or derived-coverage result is claimed. The branch producer accepts only exact focal-body spans, requires an orchestrator response receipt, records successful empty analysis separately, and runs independently of operations-case failure. | Pass #895's worker probe gate and PR #640's protected checks/review, deploy the exact worker only after the active page settles, then run a separately authorized bounded retry and verify aggregate-only source/derived/disagreement/unavailable counts at one declared cutoff without exposing record identities. |
| Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding |
| Calendar / Naruon | #355 delivered the projection contract; v2.17.0 wires operator consumption without forwarding the end-user token. Naruon producer, provider/consumer fixtures, and protected merge remain open (#336) | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired |
| SKOS organization aliases | Catalog binding and chip caption live on #480 / #482 | One catalog row per corroborated org; companion caption is hint-only until bound |
@@ -755,6 +894,7 @@ this file per §3.5 of the prior snapshot).
| Accessibility and responsive UX | #602 delivered base post-detail modal semantics; #605 adds selected-post refocus, collapsed/hidden/inert/CSS-invisible focus exclusion across both modal types, readable evidence separators, focused tests, and desktop/mobile Storybook screenshots | Land #605 through the protected gate, then complete screen-reader and authenticated Playwright acceptance on the exact release head |
| Design tokens and repeated objects | Token extraction started; sanitized Figma Event Lineage desktop/mobile frames exist, while other repeated product surfaces remain incomplete | Tokens in CSS + Storybook stories for board, popup, DAG, Ask, calendar, forms, charts; same-viewport Figma/runtime visual comparison before release |
| Frontend delivery performance | #644 implements a native dynamic-import boundary for conditional workspace surfaces and retains accessible loading/error states; exact-head checks passed but the PR is not protected-main evidence | Merge #644 normally, rebuild the protected-main production bundle, and retain the measured chunk inventory rather than raising the warning limit |
+| Authenticated read latency | ADR 0272 sets a product-owner-approved 20 ms maximum for every REST `GET` and MCP read, including authorization, database work, projection, serialization, and negotiated transfer. The Dashboard candidate separates exact authorized summary/lifecycle rollups from a stable keyset page of evidence-rich cases. On the 43,189-record runtime, the default 20-Post database page measured 2.736–3.094 ms warm, and an unrelated locale write now refreshes zero authored projections in 2.240 ms. PR #640 splits six broad-search signals into independently indexed branches while preserving exact membership and ranking: the aggregate database benchmark improved from 169.072 ms to 21.059 ms. An ID-only deduplication alternative regressed to 2,968.787 ms cold and 389.114–410.306 ms warm and was rejected. Sequential warm authenticated reads reached 4.707–13.374 ms, but the exact `e339e543` k6 harness now gates search separately and still fails under one VU's concurrent read batch: Post search averaged 102.74 ms (max 213.60), Dashboard 46.75 ms (max 93.60), Posts 51.40 ms (max 97.01), and Lineage 22.58 ms (max 31.88). PR #888 remains deliberately inactive and fail-closed: its five-way authenticated k6 observations recorded maxima from 48.280 to 127.320 ms across measured CPU profiles, disproving rather than satisfying the unchanged 20 ms gate. A later isolated full-snapshot profile verified that the native request path already uses Uvicorn 0.52.1 with uvloop 0.22.1 and httptools 0.8.0. The first cold Post search spent 87.24 ms of 89.20 ms in database work and then warmed to 11.89 ms total. Under five VUs, an approximately 78 ms cyclic-GC pause coincided with approximately 78 ms of event-loop lag, but a profiler-free GC-off authenticated k6 still failed every route maximum: 146.83 ms Posts, 216.23 ms Post search, 146.97 ms Lineage, 164.01 ms Dashboard, and 73.15 ms Ask poll across 6,875 accepted reads with no response failure. GC-off remained a discarded experiment; no production runtime or configuration changed. A later exact Dashboard-to-Project-History candidate, run in a host process against the same authorized aggregate runtime, kept every response accepted and the selected focus intact but still failed the unchanged threshold: the first accepted Dashboard/Project History reads measured 9.59/79.70 ms, and a warm five-VU, 25-journey run measured maxima of 38.05/173.52 ms across 50 accepted reads. Its Project History event-candidate plan rejected 43,188 rows in a corpus scan; the exact NFKC identity-indexed union retained ordered-row parity and reduced that statement from 24.267 ms average / 37.322 ms maximum to 0.761 / 2.495 ms. After that root fix, three independent fresh-backend five-VU series kept all 303 authenticated Dashboard and Project History reads accepted with focus intact: Dashboard maxima were 12.36, 11.86, and 12.62 ms; Project History maxima were 11.44, 11.28, and 13.87 ms. Review then removed display-name candidates so only explicit source/semantic keys admit an event; a fourth fresh five-VU series kept its 101 authenticated reads accepted and measured 13.41 ms Dashboard / 12.91 ms Project History maxima. This proves the selected Dashboard-to-History path only, not every REST/MCP read or #888 activation. Exact image head `c47cabed2` then negotiated gzip from 96,415 to 10,282 complete wire bytes; five VUs accepted 4,131 Dashboard reads at 11.43 ms average, 17.50 ms p95, and 130.43 ms maximum. That transfer optimization does not satisfy the unchanged maximum. Migration replay dropped from 60.70 s to 41.25 s after existing trigger-maintained projections stopped being rewritten; other replay work remains. The 20 ms contract is not met for Dashboard or all reads. | Keep #888 inactive and the all-read SLO gap open. Retain the exact indexed Project History candidate path and negotiated-compression assertion; treat native-parser replacement, cyclic-GC disablement, and extra worker processes as rejected fixes. Profile and remove the remaining route tails, then rerun authenticated k6 across every REST/MCP read without raising the limit or weakening exact results. |
| External integrations | Search, Zotero, calendar, Keyverse, orchestrator, RankWeave, ThreadWeave, TEPP, DiskSage, wardnet | Provider conformance, failure/reconciliation behavior, and provenance-bearing integration evidence |
| Naruon email/project lineage | #704 provides a strict store-agnostic v1 contract, opaque evidence references, observed/inferred truth separation, knowledge-cutoff admission, and explicit unavailable states. Inferred edges require an injected provenance-bearing fast-mlsirm estimate; no local default weight exists | Merge #704 through protected `main`, publish an immutable attested artifact, then enable the Naruon consumer only against that released version and its contract fixtures |
| MSA / modular reuse | LineageWeave must run standalone and as a consumer of org packages | Do not reimplement RankWeave/TEPP/orchestrator/ThreadWeave/Keyverse; fix upstream and PR there |
@@ -818,7 +958,7 @@ of leverage; open connector PRs there when the defect is upstream:
6. **ThreadWeave** — tree assembly.
7. **Naruon** — calendar and email/project lineage projection (#336, #338, #355).
8. **DiskSage / wardnet** — storage and network policy as needed.
-9. **ContextualWisdomLab/.github** — required review workflows (OpenCode, Strix, Noema) and the LineageWeave hourly caller (#1259). If stacked PRs miss central review or coverage-evidence fails on pnpm 9 (`--trust-lockfile` is pnpm 11.3) or a missing Vitest coverage provider, fix the org workflow (#1258), not a local bypass.
+9. **ContextualWisdomLab/.github** — required review workflows (OpenCode, Strix, Noema) and merged bounded hourly PR review/repair (#1380). This does not replace a commercial product-gap coordinator. If stacked PRs miss central review or coverage-evidence fails on pnpm 9 (`--trust-lockfile` is pnpm 11.3) or a missing Vitest coverage provider, fix the org workflow (#1258), not a local bypass.
## 8. Public ontology publication boundary
@@ -858,8 +998,11 @@ each: check reviews → repair → re-verify Checks → merge → continue. Chec
review latency are never blockers — keep working while they settle.
1. Revalidate Strix after merged ContextualWisdomLab/.github#1320, reconcile
- open .github#1263, and land the atomic hourly LineageWeave caller in open
- .github#1288 only through their protected gates.
+ open .github#1263, and verify open .github#1380 only as bounded hourly PR
+ review/repair through its protected gates. Keep commercial product-gap
+ development unavailable until a local manual opt-in entrypoint and the
+ central coordinator's maintainer mutation credential are independently
+ verified; closed-unmerged #1259/#1288 provide no delivery evidence.
2. Process main-targeted PRs #629, #631, #632, #639, #640, #643, #644, #657,
#658, #659, #660, and #663 only after each exact head shows terminal green
required checks plus current-head independent approval. Treat #666's
@@ -905,7 +1048,7 @@ post-merge reruns (not transferable evidence for later heads):
| ---: | --- | --- |
| #643 | Shared StatusNotice (ADR 0220): success/unavailable/retry states, WorkspaceCalendar auth-unavailable copy, 5-locale i18n; CI Full suite 22m54s green | ADR 0220 |
| #644 | Native workspace surface split: 9 conditionally rendered components as lazy() dynamic imports behind a SurfaceBoundary error boundary; build emits 9 chunks (1.5-37 kB), main bundle 543 kB; 470 frontend tests, tsc, Storybook green | — |
-| #762 | Evidence-bound project history (ADR 0243): /api/projects/{key}/history endpoint, project_history.py projection, fetchProjectHistory client, standalone ProjectHistoryTimeline component; supersedes #668 (3-way merge kept only the additive +2279/-0, dropping the branch's 8k shared-file reverts; popup UI hookup deferred as a scoped follow-up) | ADR 0243 |
+| #762 | Evidence-bound project history (ADR 0243): /api/projects/{key}/history endpoint, project_history.py projection, fetchProjectHistory client, standalone ProjectHistoryTimeline component; supersedes #668 (3-way merge kept only the additive +2279/-0, dropping the branch's 8k shared-file reverts; popup UI hookup deferred as a scoped follow-up). ADR 0276 successor work admits digest-bound interval evidence only for existing lineage edges; it does not promote time order to a business transition. | ADR 0243, ADR 0276 |
| #763 | Live-PostgreSQL A→B→A Voice history validation (ADR 0252) proving effective_from/effective_to interval replacement across repeated primary-Voice imports | ADR 0252 |
| #764 | Test-only coverage lift: observability 78%→96%, post_summary 77%→89%, claim_verification 86%→99%; package line coverage 93.5%→95% (484→371 missing); 1651 Python tests green | — |
| #761 | Temporal imported-primary Voice history (ADR 0252): migration 0243 (`effective_to` + GiST primary-period exclusion + synchronize trigger), refined 0237 `least()` effective_from backfill, `effective_from/effective_to` dataclass/export + `coalesce($2,$3)` cutoff predicate. Completes the half-shipped main layer that queried `voice.effective_to` against a missing column. CI Full suite 19m13s green | ADR 0252 |
diff --git a/docs/screenshots/source-research-desktop.png b/docs/screenshots/source-research-desktop.png
new file mode 100644
index 000000000..0629702d7
Binary files /dev/null and b/docs/screenshots/source-research-desktop.png differ
diff --git a/docs/screenshots/source-research-mobile.png b/docs/screenshots/source-research-mobile.png
new file mode 100644
index 000000000..4fee91ffa
Binary files /dev/null and b/docs/screenshots/source-research-mobile.png differ
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index f426285a6..d17049fd5 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -5,9 +5,12 @@ operator-facing control you can click before changing product CSS.
| Story | Operator next action | Token / module |
|---|---|---|
+| `Customer Master/Linking guidance` | Before linking a customer, compare the source identifier with related posts and organization evidence. `Desktop` and `Narrow` keep the same next action without exposing implementation terms. | `workspace-destination-intro`, `CustomerLinkingGuidance` |
+| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, repeat issue, or topic-context influence. `ProjectHistoryReady` opens the existing ABAC/cutoff-bound Project History timeline from an explicit synthetic project key and exercises its keyboard tablist. `TopicInfluenceAccepted` preserves exact ties, multiple membership, time states, uncertainty, and source actions; `TopicInfluenceDark`, `TopicInfluenceReducedMotion`, `TopicInfluenceKeyboard`, and `TopicInfluenceTouch` cover the ADR 0210 presentation and interaction modes. `EvidenceReady`, `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, `ConcurrentLoading`, `LoadError`, and `VoiceSummaryLoadError` cover unavailable evidence, mobile, scoped-empty, explicit evidence absence, analysis pending, retryable failure, one accessible parallel-loading announcement, whole-dashboard request failure, and independently retryable voice-summary failure. | `--color-dashboard-*`, `OperationsDashboard`, `ProjectHistoryTimeline`, `TopicContextInfluence` |
+| `Ask Agent/AnswerEvidenceTimeline` | Select an answer citation to focus its event card, select the card to return to the answer, then open its evidence, source post, or persisted related public source. `MissingObservedTime` keeps an absent event clock explicit and `NarrowViewport` verifies the single-column interaction. | `--color-accent-*`, `--radius-panel`, `--size-control-min`, `AskAnswerTimeline` |
+| `Ask Agent/Knowledge cutoff` | Ask with public verification enabled, then follow the displayed next action when no claim is eligible. `NoEligiblePublicClaim` and `NoEligiblePublicClaimNarrow` render the full result panel at desktop and mobile widths. | `ask-delivery`, `AskAgentPanel` |
| `Reports/LeftoverMapPlot` | Read the leftover-map graphic display of persisted `ξ` (posts) and `ζ` (criteria), match axis ticks to those coordinates and pair-segment `d` to leftover-map distance, then click a post marker to open that post. Leftover-map axes name persisted leftover-map axis share when finite. `ClosestAndFarthest`, `RankZeroOrigin`, `MissingCoordinates`, and `MissingAxisShare` cover two-pair maps, rank-0 origin with 0% share, a `0` tick, and `d 0.00`, omitted plots, and missing share that keeps existing leftover-map axis text. The plot does not invent a leftover score. | `LeftoverMapPlot`, `leftoverMapPlotLayout`, `leftoverMapPlotAxisShare`, `--color-primary`, `--color-palette-blue-mid` |
| `Reports/LeftoverPairList` | Read closest/farthest leftover pairs with named `R`, `Y`/`E`, rank, `U`, `s`, `e`, `x`, `R̂`, `ξ`/`ζ`, and `d`, then open that post. The leftover-map graphic display sits above the pair buttons when coordinates are finite, leftover-map axes name persisted leftover-map axis share, leftover-map axis ticks name persisted coordinates, and pair segments name persisted leftover-map distance. | `LeftoverPairList`, `LeftoverMapPlot`, `ticket-list`, `post-badge` |
-| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` |
| `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` |
| `Post/Recorded perspectives` | Read the imported primary and every evidence-connected additional Voice with its recorded truth state instead of flattening them into one compound category. `CombinedEvidence`, `RejectedEvidence`, and `NarrowViewport` cover desktop, rejected-evidence, and narrow layouts. | `VoicePerspectiveList`, `ticket-list`, `post-meta` |
| `Post/Connect perspective` | Choose one unassigned Voice and an explicit evidence state, then record the open post as its evidence. `Ready`, `Completed`, and `NarrowViewport` cover untouched, successful, and mobile states. | `VoiceAssignmentForm`, `admin-form`, `btn-primary` |
@@ -23,12 +26,24 @@ operator-facing control you can click before changing product CSS.
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
| `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` |
| `Ask Agent/Public claim verification` | Compare supported, refuted, and not-enough-information states; open only the external evidence link, then review the separate internal citation before changing governed graph state. | `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min`, `PublicClaimVerification` |
+| `Post/Source research` | Open the cited public resource, then compare it with the highlighted passage or image detail from this post. `SupportedAndUnavailable` and `PrivatePost` cover cited retrieval, fail-closed private egress, and the research action. | `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min`, `SourceResearchPanel` |
| `Ask Agent/Knowledge cutoff` | Exercise partial historical grounding, retained-revision provenance, later-live-change disclosure, and the narrow viewport before relying on a historical answer. | Native `datetime-local`, `--space-panel-block`, `--space-control-gap`, `--color-border`, `--size-control-min` |
+| `Post/ProductEvidenceList` | Open the cited product span and its connected project or operational fact. If the identity is unresolved, review the product catalog before using the relationship. Compare catalog-linked relation and catalog-review-required states. | `--surface`, `--border`, `ProductEvidenceList` |
+| `Dashboard/VoiceTaxonomySummary` | Compare source and semantic classifications, note overlapping memberships, then review disagreements and records waiting for evidence; `KoreanMobile` verifies locale-complete customer copy in the narrow viewport. | `--surface`, `--border`, `VoiceTaxonomySummary` |
+| `Navigation/WorkspaceNav` | Reach every workspace destination and the language action; `MobileAllDestinations` keeps all actions visible without horizontal clipping. | `--gnb-height`, `--size-control-min`, `WorkspaceNav` |
Repeated web objects must use `frontend/src/styles/tokens.css` and a module
under `frontend/src/components/`. Do not add a second Node package manager;
Storybook is installed with the existing pnpm pin on Node 24.
+The `Post/Source research` candidate was rendered with synthetic evidence at
+1440×1000 and an iPhone 14 viewport. The governed captures are
+[`source-research-desktop.png`](screenshots/source-research-desktop.png) and
+[`source-research-mobile.png`](screenshots/source-research-mobile.png). Desktop
+and narrow inspection confirmed readable
+wrapping without horizontal overflow, a token-sized action control, visible
+link semantics, and customer-action copy without storage or provider names.
+
## References — APA 7th
Design Tokens Community Group. (2025). *Design Tokens Format Module 2025.10*
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
index d3e118508..0577592bb 100644
--- a/frontend/.dockerignore
+++ b/frontend/.dockerignore
@@ -1,5 +1,3 @@
node_modules
dist
storybook-static
-test-results
-playwright-report
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 978eb2616..ec3b9863b 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -15,6 +15,12 @@ ENV VITE_KEYVERSE_ISSUER=${VITE_KEYVERSE_ISSUER} \
RUN pnpm run build
FROM nginx:1.27-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10
+ARG LINEAGEWEAVE_SOURCE_REVISION=unknown
+ARG VITE_KEYVERSE_ISSUER
+ARG VITE_BACKEND_BASE_URL
+LABEL org.opencontainers.image.revision=${LINEAGEWEAVE_SOURCE_REVISION} \
+ io.contextualwisdomlab.lineageweave.oidc-issuer=${VITE_KEYVERSE_ISSUER} \
+ io.contextualwisdomlab.lineageweave.backend-url=${VITE_BACKEND_BASE_URL}
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Official nginx binds :80 as root and writes its pid file to /run/nginx.pid
diff --git a/frontend/e2e/runtime-ask-evidence.spec.ts b/frontend/e2e/runtime-ask-evidence.spec.ts
new file mode 100644
index 000000000..0aa82bc39
--- /dev/null
+++ b/frontend/e2e/runtime-ask-evidence.spec.ts
@@ -0,0 +1,71 @@
+import { expect, test } from "@playwright/test";
+
+function jwtExpiry(accessToken: string): number {
+ const segments = accessToken.split(".");
+ if (segments.length !== 3) throw new Error("runtime access token must be a JWT");
+ const payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf8")) as {
+ exp?: unknown;
+ };
+ if (!Number.isInteger(payload.exp)) throw new Error("runtime access token must carry exp");
+ return payload.exp as number;
+}
+
+test("asks one operator-supplied question and opens cited evidence", async ({ page }, testInfo) => {
+ const accessToken = process.env.LINEAGEWEAVE_ACCESS_TOKEN;
+ const issuer = process.env.LINEAGEWEAVE_OIDC_ISSUER;
+ const clientId = process.env.LINEAGEWEAVE_OIDC_CLIENT_ID;
+ const question = process.env.LINEAGEWEAVE_RUNTIME_ASK_QUESTION?.trim();
+ const timeoutSeconds = Number(process.env.LINEAGEWEAVE_RUNTIME_ASK_TIMEOUT_SECONDS);
+ const screenshotPath =
+ testInfo.project.name === "chromium-mobile"
+ ? process.env.ASK_SCREENSHOT_MOBILE_PATH
+ : process.env.ASK_SCREENSHOT_DESKTOP_PATH;
+ if (
+ !accessToken ||
+ !issuer ||
+ !clientId ||
+ !question ||
+ !screenshotPath ||
+ !Number.isInteger(timeoutSeconds) ||
+ timeoutSeconds <= 0
+ ) {
+ throw new Error("runtime Ask token, OIDC, question, timeout, and screenshot environment is required");
+ }
+ test.setTimeout(timeoutSeconds * 1000);
+ if (jwtExpiry(accessToken) - Math.floor(Date.now() / 1000) < timeoutSeconds) {
+ throw new Error("runtime Ask access token expires before the declared observation budget");
+ }
+
+ await page.addInitScript(
+ ({ token, storageKey, expiresAt }) => {
+ localStorage.setItem(
+ storageKey,
+ JSON.stringify({
+ access_token: token,
+ token_type: "Bearer",
+ expires_at: expiresAt,
+ profile: { sub: "runtime-acceptance" },
+ scope: "openid",
+ }),
+ );
+ },
+ { token: accessToken, storageKey: `oidc.user:${issuer}:${clientId}`, expiresAt: jwtExpiry(accessToken) },
+ );
+ await page.goto("/");
+ await page.locator(".language-switcher select").selectOption("en");
+ await page.getByRole("button", { name: "Ask Agent" }).click();
+ await page.getByRole("textbox", { name: "Ask a question" }).fill(question);
+ await page.getByRole("button", { name: "Ask", exact: true }).click();
+ await expect(page.getByRole("heading", { name: "Answer", exact: true })).toBeVisible({
+ timeout: timeoutSeconds * 1000,
+ });
+ await expect(page.getByRole("heading", { name: "Answer evidence timeline" })).toBeVisible();
+ await page.screenshot({ path: screenshotPath, fullPage: true });
+
+ await page.getByRole("button", { name: "View evidence" }).first().click();
+ const dialog = page.getByRole("dialog");
+ await expect(dialog).toBeVisible();
+ await dialog.getByRole("button", { name: "Close evidence panel" }).click();
+ await expect(dialog).not.toBeVisible();
+ await expect(page.getByRole("heading", { name: "Answer evidence timeline" })).toBeVisible();
+});
diff --git a/frontend/e2e/runtime-operations-dashboard.spec.ts b/frontend/e2e/runtime-operations-dashboard.spec.ts
new file mode 100644
index 000000000..0b3fc0ecf
--- /dev/null
+++ b/frontend/e2e/runtime-operations-dashboard.spec.ts
@@ -0,0 +1,87 @@
+import { expect, test } from "@playwright/test";
+
+test("renders the authenticated operations Dashboard with grounded cases", async ({
+ page,
+}, testInfo) => {
+ const accessToken = process.env.LINEAGEWEAVE_ACCESS_TOKEN;
+ const issuer = process.env.LINEAGEWEAVE_OIDC_ISSUER;
+ const clientId = process.env.LINEAGEWEAVE_OIDC_CLIENT_ID;
+ const screenshotPath =
+ testInfo.project.name === "chromium-mobile"
+ ? process.env.SCREENSHOT_MOBILE_PATH
+ : process.env.SCREENSHOT_DESKTOP_PATH;
+ const requireGroundedCase = process.env.REQUIRE_GROUNDED_CASE !== "false";
+ if (!accessToken || !issuer || !clientId || !screenshotPath) {
+ throw new Error("runtime OIDC and screenshot environment is required");
+ }
+
+ await page.addInitScript(
+ ({ token, storageKey }) => {
+ const storage = (
+ globalThis as unknown as { localStorage: { setItem(key: string, value: string): void } }
+ ).localStorage;
+ storage.setItem(
+ storageKey,
+ JSON.stringify({
+ access_token: token,
+ token_type: "Bearer",
+ expires_at: Math.floor(Date.now() / 1000) + 300,
+ profile: { sub: "runtime-acceptance" },
+ scope: "openid",
+ }),
+ );
+ },
+ { token: accessToken, storageKey: `oidc.user:${issuer}:${clientId}` },
+ );
+
+ const dashboardResponse = page.waitForResponse((response) => {
+ const url = new URL(response.url());
+ return url.pathname === "/api/dashboard" && response.ok();
+ });
+ const voiceSummaryResponse = page.waitForResponse((response) => {
+ const url = new URL(response.url());
+ return url.pathname === "/api/voice-taxonomy/summary" && response.ok();
+ });
+ await page.goto("/");
+ await Promise.all([dashboardResponse, voiceSummaryResponse]);
+ const language = page.locator(".language-switcher select");
+ await language.selectOption("en");
+ await expect(page.locator("html")).toHaveAttribute("lang", "en");
+ await expect(page.getByRole("heading", { name: "Operations evidence dashboard" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Important posts over time" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Voice evidence overview" })).toBeVisible();
+ const navigation = page.getByRole("navigation", { name: "Workspace navigation" });
+ for (const label of ["Dashboard", "External information", "Board", "Customer master", "Calendar", "Ask Agent"]) {
+ await expect(navigation.getByRole("button", { name: label, exact: true })).toBeVisible();
+ }
+ await expect(page.getByText("운영 근거 대시보드")).toHaveCount(0);
+ for (const koreanLabel of ["전체 기간 · Event 발생일", "클레임 원인 규명", "재입찰 · 인수인계", "발주 공고 · 시장 동향", "반복 이슈"]) {
+ await expect(page.getByText(koreanLabel, { exact: true })).toHaveCount(0);
+ }
+ await page.screenshot({ path: screenshotPath });
+ if (requireGroundedCase) {
+ await expect(page.locator(".dashboard-case-card").first()).toBeVisible();
+ const evidenceAction = page.locator(".dashboard-case-card button").first();
+ await expect(evidenceAction).toBeVisible();
+ await evidenceAction.click();
+ const evidenceDialog = page.getByRole("dialog");
+ await expect(evidenceDialog).toBeVisible();
+ await evidenceDialog.getByRole("button", { name: "Close" }).click();
+ await expect(evidenceDialog).not.toBeVisible();
+ }
+
+ await navigation.getByRole("button", { name: "Dashboard", exact: true }).click();
+ await expect(page.getByRole("heading", { name: "Operations evidence dashboard" })).toBeVisible();
+ await language.selectOption("ko");
+ await expect(page.locator("html")).toHaveAttribute("lang", "ko");
+ await expect(page.getByRole("heading", { name: "운영 근거 대시보드" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "시간 흐름별 주요 글" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "글 유형 근거 현황" })).toBeVisible();
+ const koreanNavigation = page.getByRole("navigation", { name: "워크스페이스 메뉴" });
+ for (const label of ["대시보드", "외부 정보", "게시판", "고객 마스터", "캘린더", "에이전트에게 질문"]) {
+ await expect(koreanNavigation.getByRole("button", { name: label, exact: true })).toBeVisible();
+ }
+ await expect(page.getByText("Operations evidence dashboard")).toHaveCount(0);
+ await expect(page.getByText("전체 기간 · 사건 발생일", { exact: true })).toBeVisible();
+ await expect(page.getByText("클레임 원인 규명", { exact: true }).first()).toBeVisible();
+});
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index a3fb286f5..d98737b72 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -20,8 +20,12 @@ export default defineConfig({
},
projects: [
{
- name: "chromium",
+ name: "chromium-desktop",
use: { ...devices["Desktop Chrome"] },
},
+ {
+ name: "chromium-mobile",
+ use: { ...devices["Pixel 7"] },
+ },
],
});
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 3e4c13599..413db0816 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -204,6 +204,7 @@
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease-in-out;
+ min-height: var(--size-touch-target);
}
.btn-primary:hover {
@@ -219,12 +220,19 @@
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease-in-out;
+ min-height: var(--size-touch-target);
}
.btn-secondary:hover {
background: var(--color-btn-secondary-hover);
}
+.btn-link {
+ display: inline-flex;
+ align-items: center;
+ min-height: var(--size-touch-target);
+}
+
/* Language Switcher */
.language-switcher {
display: inline-flex;
@@ -232,7 +240,7 @@
}
.language-switcher select {
- min-height: var(--size-control-min);
+ min-height: var(--size-touch-target);
padding: 0.35rem 1.8rem 0.35rem 0.65rem;
border: 1px solid var(--border);
border-radius: var(--radius-control);
@@ -1178,19 +1186,28 @@
@media (max-width: 768px) {
/* Phone Breakpoint (<768px) */
-
+
.workspace-gnb {
- display: none; /* Replaced by drawer on mobile */
+ overflow-x: auto;
+ overscroll-behavior-inline: contain;
+ gap: 0.75rem;
+ padding: 0 1rem;
+ scrollbar-width: thin;
+ }
+
+ .workspace-gnb-item,
+ .workspace-gnb-tools {
+ flex: 0 0 auto;
}
.mobile-drawer-trigger {
- display: block;
+ display: none;
}
.app-header {
padding: 0 1rem;
}
-
+
.app-footer {
flex-direction: column;
align-items: flex-start;
@@ -1439,7 +1456,7 @@
font-weight: 700;
}
-.dashboard-period-form input { min-height: 44px; }
+.dashboard-period-form input { min-height: var(--size-touch-target); }
.operations-dashboard-heading {
display: flex;
@@ -1471,6 +1488,8 @@
.dashboard-metrics dt { color: var(--color-text); font-size: 0.875rem; }
.dashboard-metrics dd { margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700; }
+.dashboard-count-unit { white-space: nowrap; }
+
.dashboard-case-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
@@ -1484,6 +1503,31 @@
.dashboard-journey li:not(:last-child)::after { content: "→"; padding: 0 0.5rem; color: var(--color-text); }
.dashboard-journey button { display: grid; gap: 0.25rem; min-width: 10rem; min-height: var(--size-control-min); padding: 0.75rem; border: 1px solid var(--color-border); background: var(--color-background); color: var(--color-text-heading); text-align: left; }
.dashboard-journey time { color: var(--color-text); font-size: 0.75rem; }
+.dashboard-project-history { margin: 1.5rem 0; }
+.dashboard-project-history-actions { display: grid; gap: 0.75rem; margin: 1rem 0; }
+
+.dashboard-topic-table-scroll {
+ max-width: 100%;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.dashboard-topic-table-scroll table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.dashboard-topic-table-scroll th,
+.dashboard-topic-table-scroll td {
+ padding: var(--space-control-gap);
+ border-bottom: 1px solid var(--color-border);
+ text-align: left;
+ vertical-align: top;
+}
+
+.dashboard-topic-table-scroll .btn-link {
+ min-height: var(--size-touch-target);
+}
.dashboard-case-card {
display: flex;
@@ -1520,6 +1564,13 @@
.dashboard-case-card dd { margin: 0; font-weight: 600; }
.dashboard-case-card button { margin-top: auto; }
+.dashboard-milestone-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-control-gap);
+}
+
.occupation-rating-profile {
max-width: 1440px;
margin: 0 auto 2rem;
@@ -1543,7 +1594,7 @@
}
.occupation-rating-form input,
-.occupation-rating-form select { min-height: var(--size-control-min); }
+.occupation-rating-form select { min-height: var(--size-touch-target); }
.occupation-rating-source { grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); margin: 1rem 0; }
.occupation-rating-scroll-hint { display: none; }
.occupation-rating-table { overflow-x: auto; border: 1px solid var(--color-border); }
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 790c4da69..b0fa21f61 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -162,6 +162,8 @@ describe("App, authenticated", () => {
askImageCitation?: boolean;
askDelivery?: boolean;
lineageIsolationReason?: "comparison_candidates_available" | "no_comparison_group";
+ privateResearch?: boolean;
+ researchGetFailure?: boolean;
}): ReturnType & { releaseMe: () => void; releasePostOne: () => void } {
const statusLabel: Record = {
open: "Open",
@@ -1231,7 +1233,7 @@ describe("App, authenticated", () => {
},
],
total_count: 1,
- limit: 50,
+ limit: 20,
offset: 0,
...(options?.omitVoiceOptions
? {}
@@ -1258,6 +1260,16 @@ describe("App, authenticated", () => {
if (postOneUrl.pathname === "/api/posts/post-1/similar-voc") {
return Promise.resolve(jsonResponse({ items: [] }));
}
+ if (postOneUrl.pathname === "/api/posts/post-1/body") {
+ return Promise.resolve(
+ new Response(
+ postOneUrl.searchParams.has("as_of")
+ ? "The cutoff body this run knew."
+ : options?.postBody ?? "The full body text.",
+ { status: 200 },
+ ),
+ );
+ }
if (postOneUrl.pathname === "/api/posts/post-1") {
const asOf = postOneUrl.searchParams.get("as_of");
return postOneReady.then(() =>
@@ -1267,7 +1279,7 @@ describe("App, authenticated", () => {
post_body: options?.postBody ?? "The full body text.",
voc_type_code: "voc",
voc_type_label: "Voice of Customer",
- visibility_code: "public",
+ visibility_code: options?.privateResearch ? "private" : "public",
visibility_label: "Public",
project_evidence: [
{
@@ -1335,6 +1347,11 @@ describe("App, authenticated", () => {
}),
);
}
+ if (url.endsWith("/api/posts/post-2/body")) {
+ return Promise.resolve(
+ new Response("The evidence panel should show exactly this text.", { status: 200 }),
+ );
+ }
if (url.endsWith("/api/posts/post-1/evaluation")) {
return Promise.resolve(
jsonResponse({
@@ -1749,6 +1766,38 @@ describe("App, authenticated", () => {
}
return Promise.resolve(jsonResponse({ verified: [] }));
}
+ if (url.endsWith("/api/posts/post-1/research-citations")) {
+ if (method !== "POST" && options?.researchGetFailure) {
+ return Promise.resolve(new Response("unavailable", { status: 503 }));
+ }
+ return Promise.resolve(
+ jsonResponse({
+ post_id: "post-1",
+ visibility_code: "public",
+ citations:
+ method === "POST"
+ ? [
+ {
+ lead_kind_code: "research_lead_source_unit",
+ lead_source_unit_id: "unit-synthetic",
+ lead_image_region_id: null,
+ lead_excerpt_text: "Synthetic highlighted passage",
+ search_query_text: "synthetic evidence query",
+ evidence_url: "https://evidence.example/source",
+ evidence_title_text: "Synthetic cited source",
+ evidence_excerpt_text: "Synthetic public evidence excerpt",
+ judgment_code: "research_supported",
+ rationale_text: "The cited source supports the highlighted passage.",
+ next_action_text: "Open the cited source and compare the passage.",
+ },
+ ]
+ : [],
+ unavailable_reason: options?.privateResearch
+ ? "Public-source research is unavailable for this post."
+ : null,
+ }),
+ );
+ }
if (url.endsWith("/api/posts/post-1/lineage")) {
return Promise.resolve(
jsonResponse({
@@ -1961,17 +2010,21 @@ describe("App, authenticated", () => {
post_body_truncated: false,
},
],
+ related_posts_next_cursor: null,
+ related_posts_loaded: true,
resolution_status: "hint_only",
hint_trust: "normal",
provenance: "source_post.source_customer_code",
},
]
: options?.manyCustomerHints
- ? Array.from({ length: options.manyCustomerHints }, (_, index) => ({
+ ? Array.from({ length: Math.min(20, options.manyCustomerHints) }, (_, index) => ({
customer_code: `CUST-${index}`,
customer_name: resolvedHintCode === `CUST-${index}` ? "Southfield Utilities" : null,
post_count: options.manyCustomerHints! - index,
related_posts: [],
+ related_posts_next_cursor: null,
+ related_posts_loaded: true,
resolution_status: resolvedHintCode === `CUST-${index}` ? "resolved" : "hint_only",
hint_trust: "normal",
provenance: "source_post.source_customer_code",
@@ -1995,6 +2048,8 @@ describe("App, authenticated", () => {
post_body_truncated: false,
},
],
+ related_posts_next_cursor: null,
+ related_posts_loaded: true,
resolution_status: "hint_only",
provenance: "source_post.source_author_code",
},
@@ -2021,6 +2076,10 @@ describe("App, authenticated", () => {
multi_role: false,
},
],
+ source_customer_hint_total: options?.manyCustomerHints ?? (options?.hintRelatedPosts ? 1 : 0),
+ source_author_hint_total: options?.hintRelatedPosts ? 1 : 0,
+ next_customer_cursor: options?.manyCustomerHints && options.manyCustomerHints > 20 ? "next-customer" : null,
+ next_author_cursor: null,
}),
);
}
@@ -2086,7 +2145,7 @@ describe("App, authenticated", () => {
await userEvent.click(screen.getByRole("button", { name: "Ask" }));
expect(await screen.findByRole("list", { name: "Evidence facts" })).toBeInTheDocument();
- expect(screen.getByText("Semantic project", { exact: true })).toBeInTheDocument();
+ expect(screen.getByText("Related project", { exact: true })).toBeInTheDocument();
expect(screen.getByText(/project: Semantic project \| evidence: Body evidence/)).toBeInTheDocument();
expect(screen.queryByText(/ontology_iri|contextual_orchestrator/i)).not.toBeInTheDocument();
});
@@ -2128,9 +2187,10 @@ describe("App, authenticated", () => {
await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?");
await userEvent.click(screen.getByRole("button", { name: "Ask" }));
- expect(await screen.findByRole("complementary", { name: "Report · alert · MCP" })).toHaveTextContent(
+ expect(await screen.findByRole("complementary", { name: "Reports and evidence alerts" })).toHaveTextContent(
"1 evidence documents are linked to this report.",
);
+ expect(screen.queryByText(/MCP|lineageweave:\/\//i)).not.toBeInTheDocument();
expect(screen.queryByText(/근거 문서/)).not.toBeInTheDocument();
});
@@ -2218,7 +2278,7 @@ describe("App, authenticated", () => {
stubBackend();
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
expect(await screen.findByText("Demo Corp")).toBeInTheDocument();
expect(screen.getByText("DEMO-CORP-01 · Company")).toBeInTheDocument();
@@ -2238,7 +2298,7 @@ describe("App, authenticated", () => {
stubBackend({ customerEntityHierarchy: true });
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
expect(await screen.findByText("Demo Group")).toBeInTheDocument();
const subsidiaryRow = screen.getByText("Demo Corp").closest("li");
@@ -2258,7 +2318,7 @@ describe("App, authenticated", () => {
stubBackend();
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
const entityButton = (await screen.findByText("DEMO-CORP-01 · Company")).closest("button");
expect(entityButton).not.toBeNull();
@@ -2284,7 +2344,7 @@ describe("App, authenticated", () => {
stubBackend();
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
expect(await screen.findByText("Northridge Grid")).toBeInTheDocument();
expect(screen.getByText("Voice of Customer (1), Voice of Competitor (1)")).toBeInTheDocument();
@@ -2306,7 +2366,7 @@ describe("App, authenticated", () => {
stubBackend({ admin: true, manyCustomerHints: 1 });
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
expect(await screen.findByText("CUST-0")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Resolve" }));
@@ -2318,7 +2378,7 @@ describe("App, authenticated", () => {
stubBackend({ manyCustomerHints: 1 });
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
expect(await screen.findByText("CUST-0")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Resolve" })).not.toBeInTheDocument();
@@ -2334,7 +2394,7 @@ describe("App, authenticated", () => {
stubBackend({ hintRelatedPosts: true });
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
const customerSection = await screen.findByRole("region", { name: "Observed customer evidence" });
expect(within(customerSection).getByText("Related posts (1)").closest("details")).toHaveClass(
@@ -2360,12 +2420,12 @@ describe("App, authenticated", () => {
stubBackend({ manyCustomerHints: 45 });
render();
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "고객 마스터" }));
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
expect(await screen.findByText("CUST-0")).toBeInTheDocument();
- expect(screen.getByText(/Showing the first 30 of 45 observed customer identifiers/)).toBeInTheDocument();
- expect(screen.getByText("CUST-29")).toBeInTheDocument();
- expect(screen.queryByText("CUST-30")).not.toBeInTheDocument();
+ expect(screen.getByText(/Showing the first 20 of 45 observed customer identifiers/)).toBeInTheDocument();
+ expect(screen.getByText("CUST-19")).toBeInTheDocument();
+ expect(screen.queryByText("CUST-20")).not.toBeInTheDocument();
expect(screen.queryByText("CUST-44")).not.toBeInTheDocument();
});
@@ -2378,7 +2438,7 @@ describe("App, authenticated", () => {
await screen.findByRole("button", { name: "Search related posts for: Semantic project" }),
);
- const searchInput = await screen.findByRole("searchbox", { name: "Search semantic evidence" });
+ const searchInput = await screen.findByRole("searchbox", { name: "Search related evidence" });
expect(searchInput).toHaveValue("Semantic project");
expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument();
});
@@ -2407,7 +2467,8 @@ describe("App, authenticated", () => {
const board = await screen.findByRole("region", { name: "Board" });
expect(within(board).getByRole("search", { name: "Search and filter posts" })).toBeInTheDocument();
- expect(within(board).getByLabelText("Search semantic evidence")).toHaveAttribute("type", "search");
+ expect(within(board).getByLabelText("Search related evidence")).toHaveAttribute("type", "search");
+ expect(within(board).getByLabelText("Search related evidence")).not.toHaveAttribute("placeholder");
expect(within(board).getByRole("list", { name: "Board posts" })).toBeInTheDocument();
expect(within(board).getByText(/Posts shown:/)).toBeInTheDocument();
expect(within(board).getByLabelText("Voice of Partner")).toBeInTheDocument();
@@ -2417,7 +2478,7 @@ describe("App, authenticated", () => {
expect(fetchMock.mock.calls.some(([url]) => String(url).includes("sort=title"))).toBe(true),
);
- await userEvent.type(within(board).getByLabelText("Search semantic evidence"), "not found");
+ await userEvent.type(within(board).getByLabelText("Search related evidence"), "not found");
await userEvent.click(within(board).getByRole("button", { name: "Search" }));
expect(within(board).getByRole("status")).toHaveTextContent("No posts match the current filters.");
await userEvent.click(within(board).getByRole("button", { name: "Reset filters" }));
@@ -2473,6 +2534,22 @@ describe("App, authenticated", () => {
expect(screen.queryByText(new RegExp(tinyPng))).not.toBeInTheDocument();
});
+ it("shows a shared next-action notice when source text is missing", async () => {
+ stubBackend({ postBody: "" });
+ render();
+ await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
+
+ const message = await screen.findByText(
+ "The original text of this post was not imported, so its summary and related details are unavailable.",
+ );
+ const notice = message.closest("section");
+ expect(notice).toHaveClass("status-notice", "status-notice-kind-unavailable");
+ expect(notice).toHaveTextContent(
+ "Open the post directly or ask the source owner to re-import it with its body.",
+ );
+ expect(notice).not.toHaveTextContent(/semantic|ontology|provider|model|orchestrator/i);
+ });
+
it("fetches and renders the post list, then opens a detail popup on click", async () => {
const fetchMock = stubBackend();
@@ -2619,8 +2696,8 @@ describe("App, authenticated", () => {
expect(provenance).not.toHaveAttribute("open");
await userEvent.click(screen.getByText("Why this item is listed"));
expect(screen.getByText(/Category:/)).toBeInTheDocument();
- expect(screen.getByText(/How this item was found: Semantic extraction/)).toBeInTheDocument();
- expect(screen.getByText(/Recorded evidence: Stored semantic evidence/)).toBeInTheDocument();
+ expect(screen.getByText(/How this item was found: Derived from post evidence/)).toBeInTheDocument();
+ expect(screen.getByText(/Recorded evidence: Project evidence from this post/)).toBeInTheDocument();
expect(screen.queryByText("contextual_orchestrator_semantic")).not.toBeInTheDocument();
expect(screen.queryByText("https://contextualwisdomlab.github.io/LineageWeave/ontology#Project")).not.toBeInTheDocument();
expect(screen.getByText("첫 번째 이벤트")).toBeInTheDocument();
@@ -2670,13 +2747,13 @@ describe("App, authenticated", () => {
expect(keyman.compareDocumentPosition(ask) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
});
- it("labels a stale summary and retries the semantic refresh on request", async () => {
+ it("labels a stale summary and gives a buyer-facing retry action", async () => {
const fetchMock = stubBackend({ staleSummary: true });
render();
await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
await waitFor(() =>
- expect(screen.getByText("Last saved summary shown. Retry semantic refresh.")).toBeInTheDocument(),
+ expect(screen.getByText("Last saved summary shown. Retry summary refresh.")).toBeInTheDocument(),
);
const summaryCallsBeforeRetry = fetchMock.mock.calls.filter(([input]) =>
String(input).endsWith("/api/posts/post-1/summary"),
@@ -3005,7 +3082,7 @@ describe("App, authenticated", () => {
await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" }));
await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument());
await userEvent.click(
- screen.getByRole("button", { name: "Related nodes for Demo Corp (Corporate entity)" }),
+ screen.getByRole("button", { name: "Related nodes for Demo Corp (Organization)" }),
);
await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument());
expect(screen.getByText("Related to Demo Corp").closest(".related-keymen")).toHaveTextContent(
@@ -3130,6 +3207,51 @@ describe("App, authenticated", () => {
);
});
+ it("lets a post administrator research and open a cited public source", async () => {
+ const fetchMock = stubBackend({ admin: true });
+ render();
+
+ await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
+ await userEvent.click(await screen.findByRole("button", { name: "Research public sources" }));
+
+ await waitFor(() =>
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/api/posts/post-1/research-citations"),
+ expect.objectContaining({ method: "POST" }),
+ ),
+ );
+ expect(await screen.findByRole("link", { name: "Synthetic cited source" })).toHaveAttribute(
+ "href",
+ "https://evidence.example/source",
+ );
+ });
+
+ it("keeps public-source research unavailable for a private post administrator", async () => {
+ stubBackend({ admin: true, privateResearch: true });
+ render();
+
+ await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
+ expect(await screen.findByText("Public-source research is unavailable for this post.")).toBeVisible();
+ await waitFor(() =>
+ expect(screen.queryByRole("button", { name: "Research public sources" })).toBeNull(),
+ );
+ });
+
+ it("keeps the public research retry available after a citation-load failure", async () => {
+ const fetchMock = stubBackend({ admin: true, researchGetFailure: true });
+ render();
+
+ await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" }));
+ await userEvent.click(await screen.findByRole("button", { name: "Research public sources" }));
+ await waitFor(() =>
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/api/posts/post-1/research-citations"),
+ expect.objectContaining({ method: "POST" }),
+ ),
+ );
+ expect(await screen.findByRole("link", { name: "Synthetic cited source" })).toBeVisible();
+ });
+
it("lets post_admin extract Keymen from the popup", async () => {
const fetchMock = stubBackend({ admin: true });
render();
@@ -3412,7 +3534,7 @@ describe("App, authenticated", () => {
expect(await screen.findByRole("heading", { name: "Analysis runs" })).toBeInTheDocument();
const list = screen.getByRole("list", { name: "Analysis runs" });
expect(list).toHaveTextContent("Lineage reconstruction · Succeeded · Demo Corp");
- expect(list).toHaveTextContent("TEPP measurement · Failed · Demo Corp");
+ expect(list).toHaveTextContent("Calibrated event measurement · Failed · Demo Corp");
expect(list).toHaveTextContent("Period report · Succeeded · Demo Corp");
expect(list).toHaveTextContent(
"Open this run to see why it failed, then retry with the latest available records.",
@@ -3505,15 +3627,15 @@ describe("App, authenticated", () => {
await userEvent.click(
screen.getByRole("button", {
- name: "Open analysis run: TEPP measurement · Failed · Demo Corp",
+ name: "Open analysis run: Calibrated event measurement · Failed · Demo Corp",
}),
);
expect(
- await screen.findByRole("heading", { name: "TEPP measurement · Failed · Demo Corp" }),
+ await screen.findByRole("heading", { name: "Calibrated event measurement · Failed · Demo Corp" }),
).toBeInTheDocument();
const teppHistory = screen.getByRole("list", { name: "Analysis run status history" });
expect(teppHistory).toHaveTextContent("Failed 2026-01-12 12:37 · tepp_not_available");
- expect(screen.getByText(/cutoff corpus TEPP would measure/i)).toBeInTheDocument();
+ expect(screen.getByText(/selected for calibrated measurement/i)).toBeInTheDocument();
expect(teppHistory).not.toHaveTextContent("Succeeded");
});
@@ -3539,7 +3661,7 @@ describe("App, authenticated", () => {
expect(screen.getByText("The cutoff body this run knew.")).toBeInTheDocument();
expect(screen.getByText(/written 2026-01-10, known at cutoff 2026-01-12/)).toBeInTheDocument();
- const linkedPosts = screen.getAllByLabelText("Open post: Linked post");
+ const linkedPosts = await screen.findAllByLabelText("Open post: Linked post");
await userEvent.click(linkedPosts[linkedPosts.length - 1]);
await waitFor(() =>
expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(),
@@ -3593,7 +3715,7 @@ describe("App, authenticated", () => {
name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp",
});
const teppButton = screen.getByRole("button", {
- name: "Open analysis run: TEPP measurement · Failed · Demo Corp",
+ name: "Open analysis run: Calibrated event measurement · Failed · Demo Corp",
});
expect(lineageButton).toHaveTextContent(
"Open this run to see why it failed, then retry reconstruction from a current snapshot.",
@@ -3901,17 +4023,17 @@ describe("App, authenticated", () => {
await userEvent.click(
await screen.findByRole("button", {
- name: "Open analysis run: TEPP measurement · Pending · Demo Corp",
+ name: "Open analysis run: Calibrated event measurement · Pending · Demo Corp",
}),
);
expect(
- await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."),
+ await screen.findByText("These posts will be included when calibrated measurement finishes."),
).toBeInTheDocument();
expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument();
- expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/were included in this calibrated measurement result/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Start TEPP measurement" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Start calibrated measurement" })).toBeInTheDocument();
});
it("starts a pending TEPP run through tepp_client and does not invent a theta", async () => {
@@ -3920,12 +4042,12 @@ describe("App, authenticated", () => {
await userEvent.click(
await screen.findByRole("button", {
- name: "Open analysis run: TEPP measurement · Pending · Demo Corp",
+ name: "Open analysis run: Calibrated event measurement · Pending · Demo Corp",
}),
);
- await userEvent.click(screen.getByRole("button", { name: "Start TEPP measurement" }));
+ await userEvent.click(screen.getByRole("button", { name: "Start calibrated measurement" }));
expect(
- await screen.findByRole("heading", { name: "TEPP measurement · Failed · Demo Corp" }),
+ await screen.findByRole("heading", { name: "Calibrated event measurement · Failed · Demo Corp" }),
).toBeInTheDocument();
expect(screen.getByText(/tepp_not_available/)).toBeInTheDocument();
expect(screen.queryByText(/theta/i)).not.toBeInTheDocument();
@@ -3942,16 +4064,16 @@ describe("App, authenticated", () => {
await userEvent.click(
await screen.findByRole("button", {
- name: "Open analysis run: TEPP measurement · Failed · Demo Corp",
+ name: "Open analysis run: Calibrated event measurement · Failed · Demo Corp",
}),
);
expect(
await screen.findByText(
- "Connect a TEPP transport from this Failed row. Request a lineage reconstruction does not invent a measurement.",
+ "Review the failure details, confirm the selected posts and cutoff, then start a new calibrated measurement.",
),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Request a new TEPP measurement" })).not.toBeInTheDocument();
- expect(screen.queryByRole("heading", { name: "TEPP measurement · Pending · Demo Corp" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "Calibrated event measurement · Pending · Demo Corp" })).not.toBeInTheDocument();
expect(
fetchMock.mock.calls.some(
(call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST",
@@ -3965,11 +4087,11 @@ describe("App, authenticated", () => {
await userEvent.click(
await screen.findByRole("button", {
- name: "Open analysis run: TEPP measurement · Succeeded · Demo Corp",
+ name: "Open analysis run: Calibrated event measurement · Succeeded · Demo Corp",
}),
);
expect(
- await screen.findByText("These posts are the cutoff corpus this TEPP run measured."),
+ await screen.findByText("These posts were included in this calibrated measurement result."),
).toBeInTheDocument();
expect(screen.getByLabelText("Measurement request accepted")).toHaveTextContent(
"Refresh this run to check whether results are ready.",
@@ -4386,15 +4508,16 @@ describe("App, authenticated", () => {
const nav = await screen.findByRole("navigation", { name: "Workspace navigation" });
expect(nav).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "게시판" })).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("button", { name: "Board" })).toHaveAttribute("aria-current", "page");
expect(within(nav).getAllByRole("button").map((button) => button.textContent)).toEqual([
"Dashboard",
- "게시판",
- "고객 마스터",
- "달력",
+ "External information",
+ "Board",
+ "Customer master",
+ "Calendar",
"Ask Agent",
]);
- expect(nav.textContent).not.toMatch(/Buyer|Cubee|\bBoard\b|Customer master/i);
+ expect(nav.textContent).not.toMatch(/Buyer|Cubee/i);
expect(within(nav).queryByRole("button", { name: /Admin|관리자/i })).not.toBeInTheDocument();
expect(screen.queryByText("Advanced review tools")).not.toBeInTheDocument();
});
@@ -4403,8 +4526,8 @@ describe("App, authenticated", () => {
stubBackend();
render();
- await userEvent.click(await screen.findByRole("button", { name: "달력" }));
- expect(screen.getByRole("heading", { name: "달력" })).toBeInTheDocument();
+ await userEvent.click(await screen.findByRole("button", { name: "Calendar" }));
+ expect(screen.getByRole("heading", { name: "Calendar" })).toBeInTheDocument();
expect(screen.getByText("이 범위의 일정을 아직 받을 수 없습니다")).toBeInTheDocument();
expect(
screen.getByRole("region", { name: /^Unavailable:/ }),
@@ -4415,7 +4538,7 @@ describe("App, authenticated", () => {
await userEvent.click(
screen.getByRole("button", { name: /open commitment for: public post/i }),
);
- expect(await screen.findByRole("button", { name: "게시판" })).toHaveAttribute(
+ expect(await screen.findByRole("button", { name: "Board" })).toHaveAttribute(
"aria-current",
"page",
);
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e3fb6c796..b028f28c4 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -19,10 +19,12 @@ import {
fetchAnalysisRuns,
fetchCalendar,
fetchCustomerMaster,
+ fetchCustomerMasterRelatedPosts,
resolveCustomerHint,
fetchLineageGraph,
fetchMe,
fetchPost,
+ fetchPostBody,
fetchPostContent,
fetchPostActivity,
fetchPostBookmark,
@@ -32,6 +34,7 @@ import {
fetchPostEvaluation,
fetchPostKeymen,
fetchPostLineage,
+ fetchPostResearchCitations,
fetchPostFiveW1H,
fetchPostSummary,
fetchPostTickets,
@@ -47,6 +50,7 @@ import {
fetchRelatedTeam,
rebuildLineage,
rebuildPeriodReports,
+ researchPostSources,
setPostBookmark,
setPreferredLocale,
updateTicketStatus,
@@ -67,6 +71,7 @@ import {
type LineageGraph,
type Keyman,
type SourceAuthorContext,
+ type SourceResearchCitation,
type PostAiSummary,
type PostFiveW1H,
type PostDetail,
@@ -96,9 +101,12 @@ import { CutoffKnownBody } from "./components/CutoffKnownBody";
import { LineageEntityPicker } from "./components/LineageEntityPicker";
import { PopupCloseButton } from "./components/PopupCloseButton";
import { TeppAcceptedReceipt } from "./components/TeppAcceptedReceipt";
-import { chatEvidenceKindLabel } from "./evidenceKindLabels";
+import { SourceResearchPanel } from "./components/SourceResearchPanel";
import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav";
import { OccupationRatingProfile } from "./components/OccupationRatingProfile";
+import { AskAnswerTimeline } from "./components/AskAnswerTimeline";
+import { ProductEvidenceList } from "./components/ProductEvidenceList";
+import { StatusNotice } from "./components/StatusNotice";
import { initialWorkspaceDestination } from "./gnbChrome";
import { PostBody } from "./PostBody";
import { decodeHtmlEntities } from "./postBodyDisplay";
@@ -223,20 +231,30 @@ function EvidencePanel({
}) {
const [post, setPost] = useState(null);
const [postError, setPostError] = useState(false);
+ const [postBodyError, setPostBodyError] = useState(false);
useEffect(() => {
let current = true;
+ const bodyController = new AbortController();
setPost(null);
setPostError(false);
+ setPostBodyError(false);
fetchPost(accessToken, postId)
- .then((result) => {
+ .then(async (result) => {
if (current) setPost(result);
+ try {
+ const postBody = await fetchPostBody(accessToken, postId, bodyController.signal);
+ if (current) setPost({ ...result, post_body: postBody });
+ } catch {
+ if (current && !result.post_body) setPostBodyError(true);
+ }
})
.catch(() => {
if (current) setPostError(true);
});
return () => {
current = false;
+ bodyController.abort();
};
}, [postId, accessToken]);
@@ -250,10 +268,15 @@ function EvidencePanel({
{t("Source evidence is unavailable. Continue with the saved answer.")}
)}
+ {postBodyError && (
+
+ {t("The source text could not be loaded. Close this panel and try again.")}
+
{t("The original text could not be loaded. Try again to continue reviewing this post.")}
+
+
+ ) : postBody?.trim() ? (
+
) : (
-
- {t(
- "The original text of this post was not imported, so its summary and semantic extraction are unavailable. Open the post directly or ask the source owner to re-import it with its body.",
- )}
-
- {t("Last saved summary shown. Retry semantic refresh.")} {" "}
+ {t("Last saved summary shown. Retry summary refresh.")} {" "}
@@ -2830,6 +2955,15 @@ function PostDetailPopup({
/>
)}
+
+
@@ -2845,7 +2979,13 @@ function PostDetailPopup({
}
function analysisRunCaption(run: AnalysisRun): string {
- return [run.run_kind_label, run.status_label, run.scope_entity_name ?? run.scope_kind_label]
+ const customerKindLabel = {
+ analysis_run_lineage: "Lineage reconstruction",
+ analysis_run_tepp: "Calibrated event measurement",
+ analysis_run_topic_lineage: "Time-based topic analysis",
+ analysis_run_report: "Period report",
+ }[run.run_kind_code];
+ return [customerKindLabel ? t(customerKindLabel) : null, run.status_label, run.scope_entity_name ?? run.scope_kind_label]
.filter(Boolean)
.join(" · ");
}
@@ -2863,13 +3003,13 @@ function analysisRunNextAction(run: AnalysisRun): string | null {
case "analysis_status_pending":
switch (run.run_kind_code) {
case "analysis_run_lineage":
- return "Open this run, then start reconstruction. Reconstruction has not started yet.";
+ return t("Open this run, then start reconstruction. Reconstruction has not started yet.");
case "analysis_run_tepp":
- return "Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result.";
+ return t("Open this run to confirm the posts included in measurement, then start it.");
case "analysis_run_topic_lineage":
- return "Open this run to confirm which posts TEPP will thread into topic lineage. Topic-lineage analysis has not started yet — this is not a calibrated topic result.";
+ return t("Open this run to confirm the posts and time period included in topic analysis, then start it.");
case "analysis_run_report":
- return "Open this run to confirm which posts the period report will use. The report has not been built yet.";
+ return t("Open this run to confirm which posts the period report will use. The report has not been built yet.");
default: {
const unexpected: never = run.run_kind_code;
return unexpected;
@@ -2878,20 +3018,20 @@ function analysisRunNextAction(run: AnalysisRun): string | null {
case "analysis_status_failed":
switch (run.run_kind_code) {
case "analysis_run_tepp":
- return "Open this run to see why it failed, then retry with the latest available records.";
+ return t("Open this run to see why it failed, then retry with the latest available records.");
case "analysis_run_topic_lineage":
- return "Open this run to see why it failed, then retry with the latest available records.";
+ return t("Open this run to see why it failed, then retry with the latest available records.");
case "analysis_run_lineage":
- return "Open this run to see why it failed, then retry reconstruction from a current snapshot.";
+ return t("Open this run to see why it failed, then retry reconstruction from a current snapshot.");
case "analysis_run_report":
- return "Open this run to see why it failed, then rebuild the period report from a current snapshot.";
+ return t("Open this run to see why it failed, then rebuild the period report from a current snapshot.");
default: {
const unexpected: never = run.run_kind_code;
return unexpected;
}
}
case "analysis_status_running":
- return "Refresh this run. Start already queued the work on the durable outbox.";
+ return t("Refresh this run. Start already queued the work on the durable outbox.");
case "analysis_status_succeeded":
case "analysis_status_cancelled":
case null:
@@ -2907,32 +3047,26 @@ function analysisRunNextAction(run: AnalysisRun): string | null {
* Empty-corpus copy that tells the operator what to do next.
*/
function analysisRunEmptyPostsHint(run: AnalysisRun): string {
+ let analysis: string;
switch (run.run_kind_code) {
case "analysis_run_tepp":
- return (
- "No posts were available at this cutoff for TEPP to measure. " +
- "Open a later run or retry after a newer snapshot is available."
- );
+ analysis = t("calibrated measurement");
+ break;
case "analysis_run_topic_lineage":
- return (
- "No posts were available at this cutoff for topic-lineage analysis. " +
- "Open a later run or retry after a newer snapshot is available."
- );
+ analysis = t("time-based topic analysis");
+ break;
case "analysis_run_lineage":
- return (
- "No posts were available at this cutoff for reconstruction. " +
- "Open a later run or retry after a newer snapshot is available."
- );
+ analysis = t("reconstruction");
+ break;
case "analysis_run_report":
- return (
- "No posts were available at this cutoff for the period report. " +
- "Open a later run or retry after a newer snapshot is available."
- );
+ analysis = t("the period report");
+ break;
default: {
const unexpected: never = run.run_kind_code;
return unexpected;
}
}
+ return tf("No posts were available at this cutoff for {analysis}. Open a later run or retry after a newer snapshot is available.", { analysis });
}
/**
@@ -2944,28 +3078,19 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string {
function analysisRunCorpusHint(run: AnalysisRun): string | null {
const isTopicLineage = run.run_kind_code === "analysis_run_topic_lineage";
if (run.run_kind_code !== "analysis_run_tepp" && !isTopicLineage) return null;
- const service = isTopicLineage ? "topic-lineage" : "TEPP";
- const result = isTopicLineage ? "a topic-identity result" : "a calibrated result";
- const verb = isTopicLineage ? "thread" : "measure";
- const verbPast = isTopicLineage ? "threaded" : "measured";
+ const analysis = t(isTopicLineage ? "time-based topic analysis" : "calibrated measurement");
switch (run.status_code) {
case "analysis_status_failed":
- return (
- `These posts are the cutoff corpus ${service} would ${verb}. Connect a TEPP ` +
- `transport, then re-run, to replace Failed with ${result}.`
- );
+ return tf("These posts were selected for {analysis}. Review the failure details, then retry with the latest available records.", { analysis });
case "analysis_status_succeeded":
- return `These posts are the cutoff corpus this ${service} run ${verbPast}.`;
+ return tf("These posts were included in this {analysis} result.", { analysis });
case "analysis_status_pending":
case "analysis_status_running":
- return `These posts are the cutoff corpus ${service} will ${verb} once this run finishes.`;
+ return tf("These posts will be included when {analysis} finishes.", { analysis });
case "analysis_status_cancelled":
- return (
- `These posts are the cutoff corpus this ${service} run would have ${verbPast}. ` +
- `The run was cancelled before ${result}.`
- );
+ return tf("These posts were selected for {analysis}. Start a new run if the result is still needed.", { analysis });
case null:
- return `These posts are the cutoff corpus attached to this ${service} run.`;
+ return tf("These posts are selected for {analysis}.", { analysis });
default: {
const unexpected: never = run.status_code;
return unexpected;
@@ -3090,10 +3215,10 @@ function analysisRunCanStart(run: AnalysisRun): boolean {
function analysisRunStartLabel(run: AnalysisRun): string {
if (run.run_kind_code === "analysis_run_tepp") {
- return "Start TEPP measurement";
+ return "Start calibrated measurement";
}
if (run.run_kind_code === "analysis_run_topic_lineage") {
- return "Start topic lineage";
+ return "Start time-based topic analysis";
}
return "Start reconstruction";
}
@@ -3174,8 +3299,6 @@ const VISIBLE_POSTS_RENDER_LIMIT = 200;
// "Related posts" details -- collapsed by default but still mounted in the
// DOM -- pushed the page to a ~37,000px scroll height. Same pattern as
// VISIBLE_POSTS_RENDER_LIMIT above: cap the initial render, name the total.
-const HINT_RENDER_LIMIT = 30;
-
function AnalysisRunsPanel({
accessToken,
currentReportPeriod,
@@ -3385,9 +3508,9 @@ function AnalysisRunsPanel({
>
{starting
? selected.run_kind_code === "analysis_run_tepp"
- ? "Submitting the TEPP request..."
+ ? "Starting calibrated measurement..."
: selected.run_kind_code === "analysis_run_topic_lineage"
- ? "Submitting the topic-lineage request..."
+ ? "Starting time-based topic analysis..."
: "Reconstructing the cutoff bag..."
: analysisRunStartLabel(selected)}
@@ -3395,10 +3518,8 @@ function AnalysisRunsPanel({
{analysisRunCanRequestTeppRetry(selected) && (
{selected.run_kind_code === "analysis_run_topic_lineage"
- ? "Connect a TEPP transport from this Failed row. Request a " +
- "lineage reconstruction does not invent a topic model."
- : "Connect a TEPP transport from this Failed row. Request a lineage " +
- "reconstruction does not invent a measurement."}
+ ? "Review the failure details, confirm the selected posts and period, then start a new topic analysis."
+ : "Review the failure details, confirm the selected posts and cutoff, then start a new calibrated measurement."}
)}
{analysisRunReportPeriod(selected) && onSelectReportPeriod && (
@@ -4069,7 +4190,7 @@ function ReportsPanel({
);
}
-const POST_PAGE_SIZE = 50;
+const POST_PAGE_SIZE = 20;
type BoardSortOrder = PostSortOrder;
function PostList({
@@ -4369,17 +4490,16 @@ function PostList({
}}
>
-
{t("Search includes post text and semantic evidence.")}
+
{t("Search includes post text and related evidence.")}