diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0e8fce83..0f35a9ca7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,32 @@ All notable changes to this project are documented here. Format follows
## [Unreleased]
-### Added
+- 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
+
+- 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
@@ -224,6 +249,13 @@ All notable changes to this project are documented here. Format follows
### Fixed
+- 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.
- 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/backend/app/config.py b/backend/app/config.py
index a4cf71b24..325600069 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -62,6 +62,11 @@ class Settings:
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
@@ -215,6 +220,21 @@ def load_settings() -> Settings:
),
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(
diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py
index 9a32cd5d7..83f2fa065 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
@@ -52,6 +51,10 @@
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
@@ -103,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."""
@@ -188,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, ()
@@ -219,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]:
@@ -372,6 +428,7 @@ def can_see(row: asyncpg.Record) -> bool:
[],
verify_external=verify_external,
client=verification_client,
+ persisted_envelopes=(),
)
delivery = build_ask_delivery("", (), ())
return {
@@ -433,14 +490,16 @@ def can_see(row: asyncpg.Record) -> bool:
_ASK_RETRY_MESSAGE,
) from exc
cited_ids = list(answer.cited_post_ids)
- verification_status, external_claims = await _verify_public_claims(
- question_text,
- usable_sources,
- cited_ids,
- verify_external=verify_external,
- client=verification_client,
- )
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)
@@ -452,6 +511,14 @@ def can_see(row: asyncpg.Record) -> bool:
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,
+ )
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)
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index 9d9db2d59..33e6e78d1 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -189,14 +189,6 @@ async def fetch_operations_dashboard(
)
)
select (select count(*) from visible_post) as total_post_count,
- (select count(*)
- from post_summary_event summary_event
- where exists (
- select 1 from classified
- where classified.post_id = summary_event.post_id
- and ($5::boolean is false
- or classified.case_kind_code = 'external_information')
- )) as total_event_count,
(select count(distinct post_id) from classified
where case_kind_code = 'external_information') as external_post_count,
(select count(*) from scoped_post
@@ -225,10 +217,7 @@ async def fetch_operations_dashboard(
coalesce(post.event_occurred_at, post.created_at) as occurred_at,
coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)
as project_name,
- coalesce(project.project_names, array[]::text[]) as project_names,
- (select count(*)::int
- from post_summary_event summary_event
- where summary_event.post_id = classification.post_id) as event_count
+ coalesce(project.project_names, array[]::text[]) as project_names
from operations_case_classification classification
join source_post post on post.post_id = classification.post_id
join source_post evidence_post
@@ -439,10 +428,17 @@ async def fetch_operations_dashboard(
external = int(metrics["external_post_count"])
case_post_ids: dict[str, set[str]] = {}
case_event_counts: dict[str, int] = {}
+ counted_case_keys: set[tuple[str, str]] = set()
for row in case_rows:
kind = row["case_kind_code"]
- case_post_ids.setdefault(kind, set()).add(str(row["post_id"]))
- case_event_counts[kind] = case_event_counts.get(kind, 0) + int(row["event_count"])
+ post_id = str(row["post_id"])
+ case_post_ids.setdefault(kind, set()).add(post_id)
+ key = (post_id, kind)
+ if key not in counted_case_keys:
+ case_event_counts[kind] = case_event_counts.get(kind, 0) + len(
+ milestones.get(key, ())
+ )
+ counted_case_keys.add(key)
projected_cases = []
lifecycle_metrics = {
lifecycle_code: {
@@ -493,7 +489,7 @@ async def fetch_operations_dashboard(
"period_end": period_end.isoformat() if period_end else None,
"period_time_axis_code": "event_occurred_at",
"total_post_count": total,
- "total_event_count": int(metrics["total_event_count"]),
+ "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": int(metrics["pending_analysis_count"]),
diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py
index cec4b5d28..bd089accd 100644
--- a/backend/app/post_content_queue.py
+++ b/backend/app/post_content_queue.py
@@ -3,8 +3,8 @@
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
@@ -34,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()
@@ -152,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,
@@ -384,20 +404,13 @@ async def ensure_post_content_job(
POST_CONTENT_BACKFILL_CANDIDATE_SQL = f"""
select post.post_id, post.post_body
from source_post post
- left join post_content_ingestion_job job on job.post_id = post.post_id
- left join operations_case_analysis analysis
- on analysis.post_id = post.post_id
- and analysis.source_body_sha256 = job.source_body_sha256
- left join post_product_analysis product_analysis
- on product_analysis.post_id = post.post_id
- and product_analysis.source_body_sha256 = job.source_body_sha256
- left join (
- select distinct project.post_id
- from post_project_mention project
- where nullif(btrim(project.ontology_iri), '') is not null
- ) ontology_project on ontology_project.post_id = post.post_id
where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
- and (job.post_id is null or job.status_code = $1)
+ and not exists (
+ select 1
+ from post_content_ingestion_job job
+ where job.post_id = post.post_id
+ and job.status_code is distinct from $1
+ )
and (
not exists (
select 1 from post_content_unit unit
@@ -436,14 +449,45 @@ async def ensure_post_content_job(
or structure.decision_source_code = 'unresolved'
)
))
- or ($3::boolean and analysis.post_id is null)
- or ($3::boolean and product_analysis.post_id is null)
+ 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
+ where job.post_id = post.post_id
+ ))
)
and ($5::boolean = (
$3::boolean
- and ontology_project.post_id is not null
- and job.source_body_sha256 is not null
- and analysis.post_id is null
+ 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,
@@ -717,41 +761,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 (
- next_attempt_at <= now()
- or (
- next_attempt_at is null
- 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,
@@ -759,7 +820,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 f01ef119c..88f1887e2 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -6,6 +6,7 @@
import logging
import time
from collections.abc import Callable
+from datetime import datetime
from uuid import UUID
import asyncpg
@@ -26,6 +27,7 @@
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
@@ -628,6 +630,7 @@ async def process_post_content_job(
settings.orchestrator_api_key,
evidence_sources,
)
+ channel_stage_code = "product_analysis"
try:
await _persist_product_analysis_if_needed(
pool,
@@ -650,6 +653,7 @@ async def process_post_content_job(
outcome="provider_unavailable",
)
raise
+ channel_stage_code = "occupational_construct"
construct_client = (
ContextualOrchestratorOccupationalConstructExtractionClient(
settings.orchestrator_base_url,
@@ -813,13 +817,15 @@ async def consume_post_content_stream_once(
structure_factory=structure_factory,
)
last_id = str(entry_id)
+ await trim_post_content_events_through(client, last_id)
return last_id
async def _recover_post_content_jobs(
client: redis.Redis,
pool: asyncpg.Pool,
-) -> None:
+ 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(
@@ -844,7 +850,17 @@ async def _recover_post_content_jobs(
outcome="provider_unavailable",
)
try:
- await republish_queued_post_content_jobs(client, pool)
+ 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)",
@@ -855,6 +871,7 @@ async def _recover_post_content_jobs(
exc,
outcome="provider_unavailable",
)
+ return recovery_cursor
async def run_post_content_worker(
@@ -868,10 +885,13 @@ async def run_post_content_worker(
"""Run the at-least-once consumer and periodically recover queued rows."""
last_id = await _stream_tail(client)
last_recovery = 0.0
+ recovery_cursor: tuple[datetime, str] | None = None
while True:
now = time.monotonic()
if now - last_recovery >= _RECOVERY_INTERVAL_SECONDS:
- await _recover_post_content_jobs(client, pool)
+ recovery_cursor = await _recover_post_content_jobs(
+ client, pool, recovery_cursor
+ )
last_recovery = now
try:
last_id = await consume_post_content_stream_once(
diff --git a/backend/app/project_history.py b/backend/app/project_history.py
index 9389ef5fd..d203fb8c4 100644
--- a/backend/app/project_history.py
+++ b/backend/app/project_history.py
@@ -133,8 +133,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 +242,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 +261,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..0d3cf214d
--- /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."""
+
+ ...
+
+ async def execute(self, query: str, *args: object) -> Any:
+ """Execute one immutable persistence statement."""
+
+ ...
+
+ async def executemany(self, query: str, args: list[tuple[object, ...]]) -> Any:
+ """Execute bounded normalized child inserts."""
+
+ ...
+
+
+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/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/worker.py b/backend/app/worker.py
index 06bfaee10..1241aa5fc 100644
--- a/backend/app/worker.py
+++ b/backend/app/worker.py
@@ -3,6 +3,12 @@
from __future__ import annotations
import asyncio
+import logging
+from contextlib import asynccontextmanager
+from collections.abc import AsyncIterator
+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
@@ -20,8 +26,90 @@
_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.worker_health import run_worker_heartbeat
from lineageweave.observability import configure_telemetry, shutdown_telemetry
+from lineageweave.topic_influence_client import HttpTopicInfluenceClient
+
+_WORKER_LEASE_NAME = "lineageweave_durable_queue_worker"
+_logger = logging.getLogger(__name__)
+
+
+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 _single_worker_lease(pool: asyncpg.Pool) -> AsyncIterator[None]:
+ """Fail a second worker process before two stream cursors can race."""
+ async with pool.acquire() as conn:
+ acquired = bool(
+ await conn.fetchval(
+ "select pg_try_advisory_lock(hashtextextended($1, 0))",
+ _WORKER_LEASE_NAME,
+ )
+ )
+ if not acquired:
+ raise RuntimeError("another durable queue worker already owns the lease")
+ try:
+ yield
+ finally:
+ await conn.fetchval(
+ "select pg_advisory_unlock(hashtextextended($1, 0))",
+ _WORKER_LEASE_NAME,
+ )
async def run_worker_process() -> None:
@@ -30,48 +118,73 @@ async def run_worker_process() -> None:
settings = load_settings()
pool = await create_pool(settings.database_url)
valkey = create_valkey_client(settings.valkey_url)
- workers = (
- asyncio.create_task(run_worker_heartbeat()),
- 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(),
+ try:
+ async with _single_worker_lease(pool):
+ topic_influence_url = getattr(
+ settings, "topic_influence_transport_url", ""
)
- ),
- asyncio.create_task(
- run_post_content_worker(
- valkey,
- pool,
- vision_factory=_vision_client,
- embedding_factory=_embedding_client,
- structure_factory=_post_structure_client,
+ influence_timeouts = _optional_topic_influence_timeouts(
+ settings, transport_url=topic_influence_url
)
- ),
- asyncio.create_task(
- run_global_ask_worker(
- valkey,
- pool,
- chat_factory=lambda: _post_chat_client(
- timeout=load_settings().orchestrator_answer_timeout_seconds
+ workers = [
+ asyncio.create_task(run_worker_heartbeat()),
+ 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(),
+ )
),
- embedding_factory=_embedding_client,
- semantic_query_factory=_semantic_query_client,
- claim_verification_factory=_claim_verification_client_factory,
- )
- ),
- )
- try:
- await asyncio.gather(*workers)
+ asyncio.create_task(
+ run_post_content_worker(
+ valkey,
+ pool,
+ vision_factory=_vision_client,
+ embedding_factory=_embedding_client,
+ structure_factory=_post_structure_client,
+ )
+ ),
+ 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 topic_influence_url and 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:
- for worker in workers:
- worker.cancel()
- await asyncio.gather(*workers, return_exceptions=True)
try:
await pool.close()
finally:
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 45f5740f6..b0a1cb8e5 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -237,6 +237,7 @@
"0250_operations_case_analysis_input.sql",
"0251_product_semantic_catalog.sql",
"0253_voice_semantic_taxonomy.sql",
+ "0257_public_claim_envelope.sql",
)
)
@@ -5678,6 +5679,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())
diff --git a/docker-compose.yml b/docker-compose.yml
index c91a2bf08..8c011980b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -105,7 +105,9 @@ services:
build:
context: ./docker/contextual-orchestrator
dockerfile: Dockerfile
- image: ${COMPOSE_PROJECT_NAME:-lineageweave}-orchestrator:1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89
+ args:
+ CONTEXTUAL_ORCHESTRATOR_SOURCE_REVISION: 3558a9a3aeb985282b255fcd80bb2201c19ae54b
+ image: ${COMPOSE_PROJECT_NAME:-lineageweave}-orchestrator:3558a9a3aeb985282b255fcd80bb2201c19ae54b
env_file:
- ${HOME}/.env
environment:
@@ -182,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:-}
@@ -245,6 +253,8 @@ services:
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}
@@ -265,6 +275,7 @@ 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}}
+ 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
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index d793976d2..73694e3df 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -1,24 +1,49 @@
+# 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:8dcd15b023aa1205a091d7826e278713d43bc1981dbd6a7189a7382e7f69cad3 https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/3558a9a3aeb985282b255fcd80bb2201c19ae54b.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 --checksum=sha256:243e3efa9a0c2a07cd02e4620c7a40fee8b8d9c04d7df394136ab60f2635a45d https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89.tar.gz /tmp/contextual-orchestrator.tar.gz
COPY requirements.lock /tmp/orchestrator-requirements.lock
-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 --require-hashes \
+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" \
+ && 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.lock b/docker/contextual-orchestrator/requirements.lock
index 2353e7e74..9ba761162 100644
--- a/docker/contextual-orchestrator/requirements.lock
+++ b/docker/contextual-orchestrator/requirements.lock
@@ -1,9 +1,122 @@
# This file was autogenerated by uv via the following command:
-# uv pip compile docker/contextual-orchestrator/requirements.in --universal --python-version 3.12 --generate-hashes --output-file docker/contextual-orchestrator/requirements.lock
+# 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 requests
+ # 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 \
@@ -177,19 +290,84 @@ charset-normalizer==3.5.1 \
--hash=sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f \
--hash=sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0 \
--hash=sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f
- # via requests
-googleapis-common-protos==1.75.2 \
- --hash=sha256:6b83302f554ea93a0f48409c7fc2050f954bcbcddb7e3a9c76d4a823cb22920e \
- --hash=sha256:8829a3d1e4508c5b7b9a6b9525f7fccff611f8531644579a76466c29295d4bb2
- # via opentelemetry-exporter-otlp-proto-http
+ # 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 requests
+ # 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
@@ -197,27 +375,35 @@ opentelemetry-api==1.44.0 \
opentelemetry-exporter-otlp-proto-common==1.44.0 \
--hash=sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694 \
--hash=sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac
- # via opentelemetry-exporter-otlp-proto-http
+ # 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 docker/contextual-orchestrator/requirements.in
+ # 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 opentelemetry-sdk
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # opentelemetry-sdk
protobuf==7.36.0 \
--hash=sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488 \
--hash=sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16 \
@@ -228,16 +414,158 @@ protobuf==7.36.0 \
--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 opentelemetry-exporter-otlp-proto-http
+ # 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
@@ -245,4 +573,6 @@ typing-extensions==4.16.0 \
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
- # via requests
+ # via
+ # -r /tmp/contextual-4db-upstream.txt
+ # requests
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 01dc5d189..3b9e4d95a 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -23,6 +23,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")
@@ -48,6 +66,7 @@ def main() -> None:
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"
+ 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 +82,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,7 +105,6 @@ def main() -> None:
"--agents",
str(agents_path),
"--auto-discover-model-agents",
- "--allow-discovery-failures",
"--host",
"0.0.0.0",
"--port",
diff --git a/docker/contextual-orchestrator/verify_startup_contract.py b/docker/contextual-orchestrator/verify_startup_contract.py
new file mode 100644
index 000000000..202e48ed7
--- /dev/null
+++ b/docker/contextual-orchestrator/verify_startup_contract.py
@@ -0,0 +1,105 @@
+"""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.server import _run_with_routing_endpoint
+from contextual_orchestrator.token_counting import RustCl100kPacker
+from start import _configured_agents
+
+
+def main() -> None:
+ """Prove wrapper output expands into a same-origin concrete serving pool."""
+ token_packer = RustCl100kPacker()
+ assert token_packer.count_text("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
+ entrypoint.discover_all_models = lambda _sources: (catalog, [])
+ try:
+ entrypoint._auto_discover_runtime_agents(orchestrator)
+ finally:
+ entrypoint.discover_all_models = original_discovery
+
+ 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)
+ session_metadata = {"session_id": "synthetic-post-session"}
+
+ def selected_request() -> dict[str, str]:
+ candidates = orchestrator._ranked_agents("synthetic request", "worker")
+ assert [agent.id for agent in candidates] == [active_gateway[0].id]
+ return session_metadata
+
+ result = _run_with_routing_endpoint(
+ orchestrator,
+ {"endpoint": "https://gateway.synthetic.example"},
+ TaskOrchestrator.GATEWAY_DEFAULT_MODEL,
+ selected_request,
+ )
+ assert result == {"session_id": "synthetic-post-session"}
+ assert session_metadata == {"session_id": "synthetic-post-session"}
+
+
+if __name__ == "__main__":
+ main()
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/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index a12ae36ca..6040889c2 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,9 +15,16 @@ 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 `3558a9a3aeb985282b255fcd80bb2201c19ae54b` from upstream PR #857.
+The candidate pin supplies exact `Retry-After` admission deferral,
+rate-budget-derived readiness polling cadence, and endpoint-scoped structured
+admission. Readiness now measures both the internal JSON-Schema judge and the
+final JSON-object transport before a synthesizer can serve structured output;
+an explicitly requested non-admitted model fails closed. PR #857 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
@@ -37,13 +44,24 @@ 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.
+- 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.
## Consequences
diff --git a/docs/adr/0098-valkey-backed-post-content-ingestion.md b/docs/adr/0098-valkey-backed-post-content-ingestion.md
index 2acc86cf9..f86c452bb 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
@@ -103,6 +136,14 @@ 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
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/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
index 71ff03498..47e28baf2 100644
--- a/docs/adr/0206-evidence-operations-dashboard.md
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -28,11 +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. Event count is
- the number of persisted `post_summary_event` rows for the classified,
- visible posts; post count is the distinct count of those posts. Neither
- substitutes for the other, and no event is invented when a summary event
- row is absent.
+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.
@@ -204,9 +209,21 @@ treated as a negative case.
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, verifies one normalized preferred
- candidate and a positive grounded-case aggregate delta, then exercises the
- authenticated Dashboard API and rendered UI without printing source rows.
+ 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, defines no
performance threshold, and keeps its summary outside the repository. The
diff --git a/docs/adr/0210-temporal-topic-context-influence-dashboard.md b/docs/adr/0210-temporal-topic-context-influence-dashboard.md
index ad266a6da..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: consumer projection candidate; accepted producer result unavailable
+- 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
@@ -137,6 +138,66 @@ 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
diff --git a/docs/adr/0215-global-ask-public-claim-verification.md b/docs/adr/0215-global-ask-public-claim-verification.md
index ca70f566e..c3e0eda50 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 0269 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/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/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/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/0269-persisted-public-claim-admission.md b/docs/adr/0269-persisted-public-claim-admission.md
new file mode 100644
index 000000000..ded6de975
--- /dev/null
+++ b/docs/adr/0269-persisted-public-claim-admission.md
@@ -0,0 +1,55 @@
+# ADR 0269: 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/0270-digest-bound-project-journey-temporal-evidence.md b/docs/adr/0270-digest-bound-project-journey-temporal-evidence.md
new file mode 100644
index 000000000..70548e445
--- /dev/null
+++ b/docs/adr/0270-digest-bound-project-journey-temporal-evidence.md
@@ -0,0 +1,61 @@
+# ADR 0270: 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/README.md b/docs/adr/README.md
index 44bd443fd..649192595 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -21,12 +21,14 @@ 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 | [0269](0269-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) |
| [`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 | [0270](0270-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) |
diff --git a/docs/manuals/operations-manual.md b/docs/manuals/operations-manual.md
index 22d525158..c30c9b3a1 100644
--- a/docs/manuals/operations-manual.md
+++ b/docs/manuals/operations-manual.md
@@ -52,6 +52,34 @@ the canonical stack under a different project name.
Host/Origin, request-size, and k6-evidenced quota values described in the
[MCP manual](mcp-manual.md).
+For authenticated runtime acceptance, start the exact-revision stack with the
+MCP profile and declare separate provider-probe and readiness-observation
+budgets. `ORCHESTRATOR_PROBE_TIMEOUT_SECONDS` accepts 0.1 through 30 seconds;
+`ORCHESTRATOR_READINESS_TIMEOUT_SECONDS` is the positive-integer wall-clock
+budget for the asynchronous job. The acceptance runner reads the cached agent
+catalog inside the orchestrator container, probes only active agents belonging
+to the configured gateway for the structured workflow used by content
+analysis, and fails closed if no such agent becomes ready. While the job is
+pending, the runner accepts only the positive integer polling cadence declared
+by contextual-orchestrator (upstream PR #907) and never substitutes a local
+polling interval.
+
+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
diff --git a/docs/product-requirements.md b/docs/product-requirements.md
index a911445b7..b02da20be 100644
--- a/docs/product-requirements.md
+++ b/docs/product-requirements.md
@@ -386,8 +386,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 0269).
- Retrieve bounded public evidence through SearXNG and adjudicate through
contextual-orchestrator's verification mode.
- Report supported, refuted, and not-enough-information outcomes without
@@ -398,6 +399,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
@@ -453,9 +456,15 @@ comes from the cited answer rather than frontend inference.
- 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.
- 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 0270).
Acceptance: every populated fact, lifecycle endpoint, membership, and journey
event opens an authorized evidence post; an incomplete provenance chain fails
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 98a38e15a..f85b62999 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -3,9 +3,14 @@
> 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 remains remotely at `3d38f48dd3ca7e939b60f80f34ca61260c377818`;
-> its locally tested restack candidate before this documentation-only update
-> was `572ef39bf0f31882a8b1cb920f69b66d38176dab`. The open queue has 14 PRs:
+> 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 PR #857 exact
+> `3558a9a3aeb985282b255fcd80bb2201c19ae54b`; 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,
@@ -288,15 +293,15 @@ 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 |
+| 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 |
| 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 |
| 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
@@ -654,6 +659,7 @@ 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 | LineageWeave PR #781 consumes contextual-orchestrator PR #907's exact positive-integer `Retry-After`/detail agreement and admission-derived readiness polling cadence. Both #907 and its parent #857 are open stacks, so the pin is candidate integration evidence only | Merge #857 then #907 through their protected gates, repin the protected upstream merge commit, rebuild the exact LineageWeave images, and prove structured readiness plus deferred backfill recovery without exhausting the declared admission window |
| 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 |
@@ -830,7 +836,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 0270 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 0270 |
| #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/storybook-inventory.md b/docs/storybook-inventory.md
index 1413a4241..adba73286 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -6,7 +6,7 @@ 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. `TopicInfluenceAccepted` preserves exact ties, multiple membership, time states, uncertainty, and source actions; `EvidenceReady` shows the producer-contract unavailable state. `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, `ConcurrentLoading`, `LoadError`, and `VoiceSummaryLoadError` cover mobile, scoped-empty, explicit evidence-absence, analysis-pending, retryable failure, one accessible announcement for parallel loading, whole-dashboard transport failure, and independently retryable voice-summary failure. | `--color-dashboard-*`, `OperationsDashboard`, `TopicContextInfluence` |
+| `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. `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`, `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` |
| `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` |
diff --git a/frontend/e2e/runtime-ask-evidence.spec.ts b/frontend/e2e/runtime-ask-evidence.spec.ts
new file mode 100644
index 000000000..d47ff9e1d
--- /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" })).toBeVisible({
+ timeout: timeoutSeconds * 1000,
+ });
+ await expect(page.getByRole("heading", { name: "Cited posts" })).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: "Cited posts" })).toBeVisible();
+});
diff --git a/frontend/e2e/runtime-operations-dashboard.spec.ts b/frontend/e2e/runtime-operations-dashboard.spec.ts
index 7b25531d8..7a74ef77a 100644
--- a/frontend/e2e/runtime-operations-dashboard.spec.ts
+++ b/frontend/e2e/runtime-operations-dashboard.spec.ts
@@ -51,6 +51,13 @@ test("renders the authenticated operations Dashboard with grounded cases", async
}
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 page.screenshot({ path: screenshotPath, fullPage: true });
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 482e1a723..15c05af8f 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1184,7 +1184,7 @@
@media (max-width: 768px) {
/* Phone Breakpoint (<768px) */
-
+
.workspace-gnb {
overflow-x: auto;
overscroll-behavior-inline: contain;
@@ -1205,7 +1205,7 @@
.app-header {
padding: 0 1rem;
}
-
+
.app-footer {
flex-direction: column;
align-items: flex-start;
@@ -1502,6 +1502,29 @@
.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-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;
flex-direction: column;
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 382edcb30..80f02c90e 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -3483,7 +3483,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.",
@@ -3576,15 +3576,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");
});
@@ -3664,7 +3664,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.",
@@ -3972,17 +3972,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 () => {
@@ -3991,12 +3991,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();
@@ -4013,16 +4013,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",
@@ -4036,11 +4036,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.",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e702e3638..6dfd1bc69 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2904,7 +2904,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(" · ");
}
@@ -2922,13 +2928,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;
@@ -2937,20 +2943,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:
@@ -2966,32 +2972,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 });
}
/**
@@ -3003,28 +3003,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;
@@ -3149,10 +3140,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";
}
@@ -3444,9 +3435,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)}
@@ -3454,10 +3445,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 && (
diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx
index f7fb4d1c0..0681e6348 100644
--- a/frontend/src/components/OperationsDashboard.stories.tsx
+++ b/frontend/src/components/OperationsDashboard.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { expect, within } from "storybook/test";
+import { expect, userEvent, within } from "storybook/test";
import { OperationsDashboard, OperationsDashboardView } from "./OperationsDashboard";
import "../App.css";
@@ -25,7 +25,7 @@ export const EvidenceReady: Story = {
],
topic_context: {
status_code: "unavailable", reason_code: "tepp_topic_posterior_not_persisted",
- next_action: "TEPP posterior topic 계약 결과를 먼저 완료하세요.", model_run: null, topics: [],
+ next_action: "Complete the time-based analysis, then review the influential posts.", model_run: null, topics: [],
required_contracts: [
{ authority: "TEPP", schema_version: "tepp.topic_context_posterior.v1", state_code: "not_persisted" },
{ authority: "fast-mlsirm", schema_version: "fast_mlsirm.topic_context_influence.v1", state_code: "not_persisted" },
@@ -43,10 +43,10 @@ export const EvidenceReady: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await expect(canvas.getByText("9건 · 22.5%")).toBeInTheDocument();
- await expect(canvas.getByText("7 Event · 5글")).toBeVisible();
- await expect(canvas.getByText("3일 3시간 30분 0초")).toBeVisible();
- await expect(canvas.getAllByRole("button", { name: "분류 근거 글 열기" })[0]).toBeVisible();
+ await expect(canvas.getByText("9 posts · 22.5%")).toBeInTheDocument();
+ await expect(canvas.getByText("7 events · 5 posts")).toBeVisible();
+ await expect(canvas.getByText("3d 3h 30m 0s")).toBeVisible();
+ await expect(canvas.getAllByRole("button", { name: "Open classification evidence" })[0]).toBeVisible();
},
};
@@ -96,10 +96,42 @@ export const TopicInfluenceAccepted: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await expect(canvas.getByRole("heading", { name: "시간 흐름별 주요 글" })).toBeVisible();
- await expect(canvas.getByText(/휴면 \/ 재활성/)).toBeVisible();
+ await expect(canvas.getByRole("heading", { name: "Important posts over time" })).toBeVisible();
+ await expect(canvas.getByText(/Dormant \/ Reactivated/)).toBeVisible();
await expect(canvas.getAllByText("4.25")).toHaveLength(3);
- await expect(canvas.getByText(/영향도와 불확실성을 함께 비교하고 같은 값은 동점으로 확인하세요/)).toBeVisible();
+ await expect(canvas.getByText(/Compare influence and uncertainty together; identical values are ties/)).toBeVisible();
+ },
+};
+
+export const TopicInfluenceDark: Story = {
+ ...TopicInfluenceAccepted,
+ parameters: { chromatic: { prefersColorScheme: "dark" } },
+};
+
+export const TopicInfluenceReducedMotion: Story = {
+ ...TopicInfluenceAccepted,
+ parameters: { chromatic: { prefersReducedMotion: "reduce" } },
+};
+
+export const TopicInfluenceKeyboard: Story = {
+ ...TopicInfluenceAccepted,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.tab();
+ await expect(canvas.getByRole("region", { name: "Synthetic Energy Division influence table" })).toBeVisible();
+ const evidenceButton = canvas.getAllByRole("button", { name: "Open membership evidence" })[0];
+ evidenceButton.focus();
+ await expect(evidenceButton).toHaveFocus();
+ await userEvent.keyboard("{Enter}");
+ },
+};
+
+export const TopicInfluenceTouch: Story = {
+ ...TopicInfluenceAccepted,
+ parameters: { viewport: { defaultViewport: "mobile1" } },
+ play: async ({ canvasElement }) => {
+ const button = within(canvasElement).getAllByRole("button", { name: "Open influential post" })[0];
+ await expect(button).toBeVisible();
},
};
@@ -113,11 +145,11 @@ export const ExternalInformationEmpty: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await expect(canvas.getByRole("status")).toHaveTextContent("분류된 외부 정보가 없습니다");
- await expect(canvas.queryByText("전체 글")).not.toBeInTheDocument();
- await expect(canvas.queryByText("분류 Event")).not.toBeInTheDocument();
- await expect(canvas.queryByText("분석 대기")).not.toBeInTheDocument();
- await expect(canvas.queryByText("분석 실패")).not.toBeInTheDocument();
+ await expect(canvas.getByRole("status")).toHaveTextContent("No external information was classified");
+ await expect(canvas.queryByText("All posts")).not.toBeInTheDocument();
+ await expect(canvas.queryByText("Cited case events")).not.toBeInTheDocument();
+ await expect(canvas.queryByText("Awaiting analysis")).not.toBeInTheDocument();
+ await expect(canvas.queryByText("Analysis failed")).not.toBeInTheDocument();
},
};
@@ -127,7 +159,7 @@ export const RequiredFactMissing: Story = {
onOpenPost: () => undefined,
},
play: async ({ canvasElement }) => {
- await expect(within(canvasElement).getByText(/수주 Pool: 관련 근거를 찾으면 자동으로 다시 분석합니다. 이후 결과를 다시 확인하세요/)).toBeVisible();
+ await expect(within(canvasElement).getByText(/Sales pool: Find and connect the related evidence, then review the refreshed result/)).toBeVisible();
},
};
@@ -137,7 +169,7 @@ export const AnalysisPendingAndMissingEvidence: Story = {
onOpenPost: () => undefined,
},
play: async ({ canvasElement }) => {
- await expect(within(canvasElement).getByRole("status")).toHaveTextContent("분석 대기 건부터 처리하세요");
+ await expect(within(canvasElement).getByRole("status")).toHaveTextContent("Process the awaiting items first");
},
};
@@ -147,7 +179,7 @@ export const AnalysisFailed: Story = {
onOpenPost: () => undefined,
},
play: async ({ canvasElement }) => {
- await expect(within(canvasElement).getByRole("alert")).toHaveTextContent("재처리한 뒤 근거 누락 여부를 다시 확인하세요");
+ await expect(within(canvasElement).getByRole("alert")).toHaveTextContent("Reprocess 2 failed analyses");
},
};
@@ -161,8 +193,8 @@ export const LoadError: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await expect(canvas.findByRole("alert")).resolves.toHaveTextContent("불러오지 못했습니다");
- await expect(canvas.getByRole("button", { name: "다시 시도" })).toBeVisible();
+ await expect(canvas.findByRole("alert")).resolves.toHaveTextContent("could not be loaded");
+ await expect(canvas.getByRole("button", { name: "Retry" })).toBeVisible();
},
};
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index 4171de7cd..3b8b4fbab 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchOperationsDashboard, fetchVoiceTaxonomySummary, type OperationsDashboardResponse } from "../api";
+import { setLocale } from "../i18n";
import { OperationsDashboard, OperationsDashboardView } from "./OperationsDashboard";
vi.mock("../api", async (importOriginal) => ({
@@ -11,6 +12,7 @@ vi.mock("../api", async (importOriginal) => ({
}));
beforeEach(() => {
+ setLocale("ko");
vi.mocked(fetchVoiceTaxonomySummary).mockReset().mockResolvedValue({
total_eligible: 0, classified_unique: 0, multi_membership: 0,
source_count: 0, derived_count: 0, unavailable: 0, disagreement: 0,
@@ -61,10 +63,10 @@ describe("OperationsDashboardView", () => {
it("distinguishes posts, events, percentages and opens evidence", async () => {
const onOpenPost = vi.fn();
render();
- expect(screen.getByText("3 Event · 2글")).toBeInTheDocument();
+ expect(screen.getByText("사건 Event 3건 · 글 2건")).toBeInTheDocument();
expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument();
expect(screen.getByText("원인 수주")).toBeInTheDocument();
- expect(screen.getByText(/수주 Pool: 관련 근거를 찾으면 자동으로 다시 분석합니다. 이후 결과를 다시 확인하세요/)).toBeInTheDocument();
+ expect(screen.getByText(/수주 Pool: 관련 근거를 찾아 연결한 뒤 갱신된 결과를 확인하세요/)).toBeInTheDocument();
expect(screen.getByText("2일 3시간 30분 0초")).toBeInTheDocument();
expect(screen.getByText(/진행 중 1건 · 종료 확인 0건/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" }));
@@ -75,6 +77,54 @@ describe("OperationsDashboardView", () => {
expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1");
});
+ it.each([
+ ["en", "Operations evidence dashboard", "All posts", "Important posts over time"],
+ ["zh", "运营证据看板", "全部文章", "时序重要文章"],
+ ["ja", "運用エビデンスダッシュボード", "すべての投稿", "時系列の重要投稿"],
+ ["vi", "Bảng điều khiển bằng chứng vận hành", "Tất cả bài viết", "Bài viết quan trọng theo thời gian"],
+ ] as const)("renders localized dashboard actions in %s", (locale, heading, allPosts, importantPosts) => {
+ setLocale(locale);
+ render( undefined} />);
+ expect(screen.getByRole("heading", { name: heading })).toBeInTheDocument();
+ expect(screen.getByText(allPosts)).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: importantPosts })).toBeInTheDocument();
+ expect(screen.queryByText("운영 근거 대시보드")).not.toBeInTheDocument();
+ if (locale !== "en") expect(screen.queryByText("Operations evidence dashboard")).not.toBeInTheDocument();
+ expect(screen.queryByText(/TEPP|fast-mlsirm|transport|topic-lineage/i)).not.toBeInTheDocument();
+ });
+
+ it("keeps the server next action for an unknown lifecycle status", () => {
+ setLocale("en");
+ const future = {
+ ...data.cases[0].lifecycles[0],
+ status_code: "future_status" as never,
+ status_label: "Future status",
+ next_action_text: "Open the cited evidence, then choose the next owner.",
+ };
+ render( undefined} />);
+
+ expect(screen.getByText(/Open the cited evidence, then choose the next owner/)).toBeInTheDocument();
+ });
+
+ it.each([
+ ["en", "Open the available evidence, then confirm the next action."],
+ ["ko", "확인 가능한 근거를 연 뒤 다음 조치를 확인하세요."],
+ ["zh", "打开可用证据,然后确认下一步行动。"],
+ ["ja", "確認できる根拠を開き、次の行動を確認してください。"],
+ ["vi", "Mở bằng chứng hiện có rồi xác nhận hành động tiếp theo."],
+ ] as const)("keeps an actionable fallback for an unknown lifecycle status in %s", (locale, nextAction) => {
+ setLocale(locale);
+ const future = {
+ ...data.cases[0].lifecycles[0],
+ status_code: "future_status" as never,
+ status_label: "Future status",
+ next_action_text: undefined as unknown as string,
+ };
+ render( undefined} />);
+
+ expect(screen.getByText(new RegExp(nextAction.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))).toBeInTheDocument();
+ });
+
it("does not imply that only the end evidence is missing", () => {
const openLifecycle = {
...data.cases[0].lifecycles[0],
@@ -96,7 +146,7 @@ describe("OperationsDashboardView", () => {
expect(screen.queryByText("분석 대기")).not.toBeInTheDocument();
expect(screen.queryByText("분석 실패")).not.toBeInTheDocument();
expect(screen.queryByText("전체 글")).not.toBeInTheDocument();
- expect(screen.queryByText("분류 Event")).not.toBeInTheDocument();
+ expect(screen.queryByText("근거 확인된 사건 Event")).not.toBeInTheDocument();
});
it("does not label a scoped external count with a corpus-wide rate", () => {
@@ -245,7 +295,7 @@ describe("OperationsDashboardView", () => {
});
render( undefined} />);
- expect(await screen.findByText("Dashboard 근거를 불러오지 못했습니다.")).toBeInTheDocument();
+ expect(await screen.findByText("대시보드 근거를 불러오지 못했습니다.")).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Voice evidence overview" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "다시 시도" })).toBeInTheDocument();
});
@@ -257,7 +307,7 @@ describe("OperationsDashboard", () => {
vi.mocked(fetchVoiceTaxonomySummary).mockImplementation(() => new Promise(() => undefined));
render( undefined} />);
expect(screen.getAllByRole("status")).toHaveLength(1);
- expect(screen.getByRole("status")).toHaveTextContent("Dashboard 근거를 불러오는 중입니다.");
+ expect(screen.getByRole("status")).toHaveTextContent("대시보드 근거를 불러오는 중입니다.");
expect(screen.getByRole("status")).toHaveTextContent("Loading voice evidence...");
});
});
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index 19cc49aaf..fe2b0d504 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -1,28 +1,94 @@
import { useEffect, useState } from "react";
import { fetchOperationsDashboard, fetchVoiceTaxonomySummary, type OperationsDashboardResponse, type VoiceTaxonomySummary as VoiceSummary } from "../api";
-import { t } from "../i18n";
+import { t, tf, useLocale } from "../i18n";
import { VoiceTaxonomySummary } from "./VoiceTaxonomySummary";
function formatElapsed(seconds: number): string {
const days = Math.floor(seconds / 86_400);
const hours = Math.floor((seconds % 86_400) / 3_600);
const minutes = Math.floor((seconds % 3_600) / 60);
- return `${days}일 ${hours}시간 ${minutes}분 ${seconds % 60}초`;
+ return tf("{days}d {hours}h {minutes}m {seconds}s", { days, hours, minutes, seconds: seconds % 60 });
}
const dimensionLabels = {
- business_unit: "사업부",
+ business_unit: "Business unit",
process_unit: "PU",
- team: "팀",
- person: "개인",
+ team: "Team",
+ person: "Person",
} as const;
const topicStateLabels = {
- active: "활성",
- dormant: "휴면",
- reactivated: "재활성",
+ active: "Active",
+ dormant: "Dormant",
+ reactivated: "Reactivated",
} as const;
+const topicEventLabels = { birth: "Started", split: "Split", merge: "Merged", retirement: "Ended" } as const;
+
+const caseKindLabels: Record = {
+ claim_investigation: "Claim investigation",
+ rebid_handover: "Rebid and handover",
+ external_information: "External information",
+ repeat_issue: "Recurring issue",
+};
+
+const factTypeLabels: Record = {
+ order: "Affected order",
+ specification_change: "Specification change",
+ originating_order: "Originating order",
+ sales_pool: "Sales pool",
+ discussion: "Discussion",
+ counterparty: "Counterparty",
+ our_owner: "Our owner",
+ decision: "Decision",
+ external_relation: "Business relationship",
+ issue_pattern: "Recurring pattern",
+ improvement_action: "Improvement action",
+};
+
+const lifecycleLabels: Record = {
+ claim_investigation: "Claim investigation",
+ rebid_response: "Rebid response",
+ handover_gap: "Handover gap",
+};
+
+const lifecycleStatusLabels: Record = {
+ open: "In progress",
+ resolved: "Completed",
+ evidence_missing: "Timing evidence needed",
+};
+
+const milestoneLabels: Record = {
+ claim_received: "Claim received",
+ cause_confirmed: "Cause confirmed",
+ rebid_started: "Rebid started",
+ response_submitted: "Response submitted",
+ handover_started: "Handover started",
+ handover_completed: "Handover completed",
+};
+
+const timeAxisLabels: Record = {
+ event_occurred_at: "Event date",
+ created_at: "Record creation date",
+};
+
+const relationTargetLabels: Record = {
+ order: "Order",
+ project: "Project",
+ sales: "Sales",
+ business_management: "Business management",
+};
+
+const lifecycleNextActions: Record = {
+ open: "Open the start evidence and track the next observed event.",
+ resolved: "Open the start and end evidence, then review the elapsed time.",
+ evidence_missing: "Find and connect the required start and end evidence, then review the refreshed interval.",
+};
+
+function controlledLabel(code: string, fallback: string, labels: Record): string {
+ return t(labels[code] ?? fallback);
+}
+
type Props = {
accessToken: string;
externalOnly?: boolean;
@@ -31,6 +97,7 @@ type Props = {
/** Shows quantified operational cases and opens their cited source posts. */
export function OperationsDashboard({ accessToken, externalOnly = false, onOpenPost }: Props) {
+ useLocale();
const [data, setData] = useState(null);
const [error, setError] = useState(false);
const [periodStart, setPeriodStart] = useState("");
@@ -67,15 +134,15 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP
event.preventDefault();
setSubmittedPeriod([periodStart, periodEnd]);
}}>
-
-
-
+
+
+
{error ? (
-
{t("Next action")}: {t(lifecycleNextActions[lifecycle.status_code] ?? lifecycle.next_action_text ?? "Open the available evidence, then confirm the next action.")}
{fact.fact_type_label}: 관련 근거를 찾으면 자동으로 다시 분석합니다. 이후 결과를 다시 확인하세요.
)}
+
+
{t("Additional evidence needed")}
+
{item.missing_facts.map((fact) =>
{tf("{label}: Find and connect the related evidence, then review the refreshed result.", { label: controlledLabel(fact.fact_type_code, fact.fact_type_label, factTypeLabels) })}
{externalOnly ? "선택 기간에 분류된 외부 정보가 없습니다. 기간이나 접근 범위를 확인하세요." : data.pending_analysis_count > 0 ? "선택 기간에 분석 완료된 근거가 없습니다. 분석 대기 건부터 처리하세요." : "선택 기간에 분석할 수 있는 근거가 없습니다. 기간이나 접근 범위를 확인하세요."}
+
{t(externalOnly ? "No external information was classified in this period. Check the period or your access scope." : data.pending_analysis_count > 0 ? "No evidence has completed analysis in this period. Process the awaiting items first." : "No evidence can be analyzed in this period. Check the period or your access scope.")}