diff --git a/AGENTS.md b/AGENTS.md index c927f9e61..78d0350b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -324,11 +324,14 @@ A run-bearing analysis-run registry empties only after an unrevoked (ADR 0020 / v0.87.0). The documented phrase is not a secret. Do not expose purge on a public HTTP route. -`POST /api/analysis-runs` records Pending lineage only (ADR 0017 / -v2.7.1). TEPP and period-report kinds 422 before any snapshot write. -`POST /api/analysis-runs/{id}/start` reconstructs a Pending lineage -cutoff bag through `reconstruct()` / `lineage_edge_specs` (ADR 0021 / -v0.88.0). Do not invent a theta. +`POST /api/analysis-runs` records Pending lineage, TEPP measurement, and +topic-lineage requests (ADR 0017 / ADR 0022); period-report and unknown kinds +422 before any snapshot write. A Failed measurement remains terminal, while +Retry creates and starts a new current-snapshot request through the outbox and +`tepp_client`. `POST /api/analysis-runs/{id}/start` reconstructs a Pending +lineage cutoff bag through `reconstruct()` / `lineage_edge_specs` (ADR 0021 / +v0.88.0) or submits supported measurement work through `tepp_client`. Do not +invent a theta. Opening a cutoff-rewritten title shows **Body this run knew** from `source_post_revision` beside the live rewrite (ADR 0025 / v2.1.0). Do not invent the earlier sentence when no revision covers the cutoff. @@ -375,8 +378,9 @@ A manual dispatch from another ref is not a publication path. The repository-case public namespace `https://contextualwisdomlab.github.io/LineageWeave/ontology#` is canonical ([ADR 0207](docs/adr/0207-repository-case-ontology-namespace-canonical.md), -superseding ADR 0157, resolving issue #372); the lowercase form is a -deprecated compatibility vocabulary with validated term-kind mappings. +superseding ADR 0157 and amended by ADR 0236); the lowercase form is a +deprecated compatibility identifier mapped by a published compatibility +vocabulary, not a separately served namespace. New runtime values, exports, fixtures, and database rows mint only repository-case IRIs; `scripts/migrate_legacy_namespace.py` rewrites stored lowercase IRIs (dry-run by default, never touching provenance diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 399894584..fcfcc4157 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -20,11 +20,21 @@ authorization, provenance persistence, and UI projection only. The current Python IRT/report, residual-map, similarity, graph-ranking, and fusion paths are explicitly inventoried migration debt rather than evidence that this repository owns their mathematics. They move by construct to TEPP, -fast-mlsirm, or RankWeave after versioned Rust CPU/GPU owner contracts pass -recovery/equivalence checks; affected product paths fail closed during each -cutover rather than substituting a local estimate. See +fast-mlsirm, or RankWeave after each owner's versioned contract passes +recovery/equivalence checks; TEPP and fast-mlsirm own Rust scientific compute, +while RankWeave owns its existing retrieval and comparison implementation. +Affected product paths fail closed during each cutover rather than substituting +a local estimate. See `docs/doctoring/python-mathematical-compute-boundary-audit.md`. +ADR 0237 also keeps accelerator deployment outside this repository. MLX runs +as a native Apple-silicon inference service behind contextual-orchestrator; +scientific CPU/CUDA/OpenCL profiles belong to TEPP or fast-mlsirm. RankWeave +remains the dependency-free Python retrieval-fusion/evaluation owner behind its +published contract. LineageWeave Compose therefore does not reserve devices or +mount host drivers; its provider-neutral connectors consume versioned results +and fail closed when an owning service is unavailable. + ## Data flow ```mermaid diff --git a/CHANGELOG.d/2.21.2-cancelled-analysis-next-actions.md b/CHANGELOG.d/2.21.2-cancelled-analysis-next-actions.md new file mode 100644 index 000000000..5fb2e6c89 --- /dev/null +++ b/CHANGELOG.d/2.21.2-cancelled-analysis-next-actions.md @@ -0,0 +1,5 @@ +# 2.21.2 Name the next action for every cancelled analysis kind + +Cancelled lineage, TEPP, topic-lineage, and period-report runs now tell the +operator the kind-specific valid next action. A terminal cancellation no +longer leaves the analysis list and detail without guidance. diff --git a/CHANGELOG.md b/CHANGELOG.md index 641306055..545d79d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ All notable changes to this project are documented here. Format follows ### Added +- Failed measurement and topic-lineage views can request and start a new + immutable current-snapshot run while retaining the Failed history. +- Analysis-run next actions, empty-snapshot guidance, and selected-post + guidance now follow the active English, Korean, Chinese, Japanese, or + Vietnamese locale. Customer copy describes the next product action without + exposing internal measurement transport boundaries; Storybook includes + desktop and phone status matrices. +- Account-owned per-post Ask conversations can be listed, reopened, and + continued with current authorization reapplied to cited evidence (ADR 0235). - Persist explicit paragraph, list, table, MathML formula, and caller-parsed conversation-turn semantic-unit kinds without inferring absent boundaries. - Event Lineage now persists each reconstructed connection's independent @@ -105,6 +114,10 @@ All notable changes to this project are documented here. Format follows ### Changed +- Ontology documentation now distinguishes the canonical dereferenceable + namespace from the published mapping document for legacy lowercase IRIs; + no customer or consumer is directed to a lowercase path that returns 404. + - ADRs 0011 and 0065 now include APA 7th References for the dated W3C PROV-O and PROV-DM Recommendations (30 April 2013). Decisions are unchanged. diff --git a/CLAUDE.md b/CLAUDE.md index eb9e85eab..51244f34d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,8 +27,9 @@ period-report run on one snapshot. The TEPP path goes through Failed (`tepp_not_available` / `tepp_result_not_persisted`). Never invent a theta or a local psychometric substitute. -- Failed TEPP is terminal -- open that row and connect a live TEPP - transport from it. +- Failed TEPP is terminal. Retry creates and starts a new current-snapshot + request through the existing outbox and `tepp_client`; it never mutates the + failed row or invents a local measurement. - A failed lineage row retries reconstruction and does not mention TEPP; a failed period-report row rebuilds the report. - Pending rows claim nothing: pending TEPP is not a calibrated diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 7335f6fb9..8487a00d2 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -6,10 +6,9 @@ lookup labels come back; source SQL, DSNs, raw records, and provider payloads never do. -``create_pending_analysis_run`` (ADR 0017) writes snapshot, counts, frozen -membership, run, scope, and the first Pending event atomically. It -records lineage only. It does not reconstruct lineage, accept a TEPP -kind, or invent a score. ``enqueue_pending_analysis_run`` then +``create_pending_analysis_run`` (ADR 0017 / ADR 0022) writes snapshot, +counts, frozen membership, run, scope, and the first Pending event atomically. +It records a request, never a result or score. ``enqueue_pending_analysis_run`` then ``deliver_queued_analysis_run`` later reconstruct lineage (ADR 0021 / ADR 0023) or submit TEPP through ``tepp_client`` (ADR 0022). Neither path invents a TEPP score. @@ -333,6 +332,8 @@ async def _serialize_runs( } if row["scope_entity_name"]: item["scope_entity_name"] = row["scope_entity_name"] + if row["corporate_entity_id"]: + item["scope_corporate_entity_id"] = str(row["corporate_entity_id"]) if row["scope_key"]: item["scope_key"] = row["scope_key"] grouping_key = scope_grouping_key(row) @@ -629,36 +630,25 @@ def __init__(self, status_code: int, detail: str) -> None: self.detail = detail -def _require_lineage_create_kind(run_kind_code: str) -> None: - """Reject TEPP, topic-lineage, and report writes so this path cannot fake those products. +def _require_requestable_run_kind(run_kind_code: str) -> None: + """Allow requestable run kinds without claiming that work has completed. - TEPP and topic-lineage stay ``tepp_client`` wire paths (ADR 0022 / - ADR 0132). Period reports stay on the Reports panel rebuild. A Pending - TEPP or topic-lineage row that never called the transport is a - fabricated measurement request. + TEPP and topic-lineage execution still crosses ``tepp_client`` (ADR 0022 / + ADR 0132). A Pending row is an immutable request, not a calibrated result. + Period reports stay on the Reports panel rebuild path. """ - if run_kind_code == _TEPP_RUN_KIND: - raise AnalysisRunCreateError( - 422, - "Connect a TEPP transport from a Failed TEPP row; this endpoint " - "does not invent a measurement.", - ) - if run_kind_code == _TOPIC_LINEAGE_RUN_KIND: - raise AnalysisRunCreateError( - 422, - "Connect a TEPP transport from a Failed topic-lineage row; this " - "endpoint does not invent a topic model.", - ) + if run_kind_code in { + _LINEAGE_RUN_KIND, + _TEPP_RUN_KIND, + _TOPIC_LINEAGE_RUN_KIND, + }: + return if run_kind_code == _REPORT_RUN_KIND: raise AnalysisRunCreateError( 422, "Rebuild the period report from the Reports panel.", ) - if run_kind_code != _LINEAGE_RUN_KIND: - raise AnalysisRunCreateError( - 422, - "Only lineage reconstruction can be requested here.", - ) + raise AnalysisRunCreateError(422, "Choose a supported analysis run kind.") @dataclass(frozen=True) @@ -785,11 +775,12 @@ async def create_pending_analysis_run( ) -> dict[str, Any]: """Insert snapshot, counts, frozen members, run, scope, and Pending. - Lineage only. Does not reconstruct, call TEPP, or invent a theta. + Does not reconstruct, call TEPP, or invent a theta. Pending measurement + and topic-lineage rows become work only through the start/outbox path. Kind rejection happens before any snapshot or run insert. Idempotent retries compare ``configuration_sha256``. """ - _require_lineage_create_kind(run_kind_code) + _require_requestable_run_kind(run_kind_code) if scope_kind_code != _CORPORATE_SCOPE: raise AnalysisRunCreateError( 422, diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index c08810078..8fd22e907 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -954,9 +954,9 @@ async def _claim_delivery_plan( if weights is None: raise AnalysisRunStartError( 503, - "Channel weights are not estimated yet for this run's active " - f"channels ({', '.join(sorted(active_channels))}). Run " - "scripts/estimate_channel_weights.py, then start this run again.", + "Lineage calibration evidence is unavailable for this run. Ask " + "your workspace administrator to complete calibration, then start " + "this run again.", ) return _DeliveryPlan( _LINEAGE_KIND, @@ -986,9 +986,9 @@ def _execute_delivery_plan( except _AdjudicationProviderError as exc: raise AnalysisRunStartError( 503, - "The adjudication provider failed mid-reconstruction; nothing " - "was persisted. Check the contextual-orchestrator transport, " - "then start this run again.", + "Lineage reconstruction is temporarily unavailable. No result " + "was saved. Start this run again in a moment; if this continues, " + "contact your workspace administrator.", ) from exc return _DeliveryOutcome(plan.work_kind_code, plan.started_at, tuple(edges)) diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index 9bffd8502..5be5ab924 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -76,6 +76,10 @@ # trimmed stream) and are republished by the worker's recovery sweep. _REPUBLISH_AFTER_SECONDS = 60 _RECOVERY_INTERVAL_SECONDS = 30.0 +_ASK_RETRY_MESSAGE = ( + "Ask Agent is unavailable. Retry in a moment. If this continues, " + "contact your workspace administrator." +) # Hard ceiling on one job's answer computation. Without it a hung # orchestrator round-trip kept a job `running` indefinitely (observed: # 17+ minutes) and, before concurrent processing, stalled every job @@ -168,11 +172,11 @@ def _verification_next_action(status_code: str) -> str: return { VERIFICATION_SKIPPED: "Enable public verification to check eligible public claims.", - VERIFICATION_UNAVAILABLE: "Configure public search and contextual-orchestrator, then retry.", - VERIFICATION_NO_PUBLIC_CLAIMS: "Inspect the internal cited posts; no public claim was eligible.", + VERIFICATION_UNAVAILABLE: "Ask a workspace administrator to enable public verification, then retry.", + VERIFICATION_NO_PUBLIC_CLAIMS: "Ask about a specific claim or narrow the time range, then retry.", VERIFICATION_COMPLETED: "Inspect public evidence separately before any governed graph review.", CLAIM_NOT_ENOUGH_INFORMATION: "Collect stronger authoritative evidence before accepting the claim.", - }.get(status_code, "Inspect the authorized cited posts and their evidence.") + }.get(status_code, "Ask about a specific claim or narrow the time range, then retry.") async def _verify_public_claims( @@ -348,7 +352,7 @@ def can_see(row: asyncpg.Record) -> bool: record_server_failure("global_ask", exc, outcome="internal_error") raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Ask Agent is unavailable: authorized evidence could not be assembled", + _ASK_RETRY_MESSAGE, ) from exc cutoff_text = knowledge_cutoff.isoformat() if knowledge_cutoff else None grounding_status = ask_grounding_status(sources, cutoff_text) @@ -404,7 +408,7 @@ def can_see(row: asyncpg.Record) -> bool: record_server_failure("global_ask", exc, outcome="provider_unavailable") raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Ask Agent is unavailable: contextual-orchestrator could not complete the answer", + _ASK_RETRY_MESSAGE, ) from exc except (KeyError, ValueError) as exc: # Contract/schema fault: the orchestrator responded but its payload @@ -413,7 +417,7 @@ def can_see(row: asyncpg.Record) -> bool: record_server_failure("global_ask", exc, outcome="provider_unavailable") raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Ask Agent is unavailable: contextual-orchestrator could not complete the answer", + _ASK_RETRY_MESSAGE, ) from exc except Exception as exc: # Unexpected defect. Keep the customer boundary and emit a full @@ -422,7 +426,7 @@ def can_see(row: asyncpg.Record) -> bool: record_server_failure("global_ask", exc, outcome="internal_error") raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Ask Agent is unavailable: contextual-orchestrator could not complete the answer", + _ASK_RETRY_MESSAGE, ) from exc cited_ids = list(answer.cited_post_ids) verification_status, external_claims = await _verify_public_claims( @@ -552,7 +556,7 @@ async def process_global_ask_job( chat_client = chat_factory() if not chat_client.available: raise _SafeJobError( - "Ask Agent is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY" + _ASK_RETRY_MESSAGE ) payload = await asyncio.wait_for( compute_global_ask_answer( @@ -588,16 +592,13 @@ async def process_global_ask_job( # state / missing config) — never a provider-boundary leak. detail = str(exc) elif isinstance(exc, asyncio.TimeoutError): - detail = f"job exceeded the {JOB_DEADLINE_SECONDS}s deadline" + detail = _ASK_RETRY_MESSAGE else: # Provider responses/exceptions can carry credentials, gateway # diagnostics, or model output (ADR 0123): never persist the # raw exception text as a durable `failure_detail`. The # traceback just logged keeps it for operator debugging only. - detail = ( - "Ask Agent is unavailable: contextual-orchestrator returned " - "no complete evidence object" - ) + detail = _ASK_RETRY_MESSAGE async with pool.acquire() as conn: await conn.execute( """ diff --git a/backend/app/main.py b/backend/app/main.py index 6457bbde1..35c82bb85 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -135,6 +135,14 @@ gather_chat_sources, persist_post_chat, ) +from backend.app.post_ask_history import ( + PostAskConversationNotFound, + PostAskEvidenceChanged, + conversation_exists as post_ask_conversation_exists, + fetch_conversation as fetch_post_ask_conversation, + list_conversations as list_post_ask_conversations, + persist_turn as persist_post_ask_turn, +) from backend.app.post_content_queue import ( ensure_post_content_job, post_content_api_status, @@ -332,6 +340,10 @@ async def lifespan(app: FastAPI): logger = logging.getLogger(__name__) +_POST_CHAT_RETRY_DETAIL = ( + "Post chat is temporarily unavailable. Review the saved evidence, then " + "retry in a moment. If this continues, contact your workspace administrator." +) app = FastAPI(title="LineageWeave API", lifespan=lifespan) app.add_middleware( @@ -1258,12 +1270,12 @@ async def resolve_customer_master_hint( # verify-relations' identical try/except). raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Hint resolution is unavailable: the orchestrator or search provider did not respond", + "Hint resolution is temporarily unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Hint resolution is unavailable: the orchestrator or search provider did not respond", + "Hint resolution is temporarily unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc if resolution is None: raise HTTPException( @@ -1308,8 +1320,7 @@ async def rebuild_lineage_graph( except ChannelWeightsNotEstimated as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Channel weights are not estimated yet. Run " - "scripts/estimate_channel_weights.py, then rebuild again.", + "Lineage calibration evidence is unavailable. Ask your workspace administrator to complete calibration, then rebuild again.", ) from exc except (HttpClientError, OSError) as exc: # This can issue up to MAXIMUM_LIVE_LLM_PAIR_EVALUATIONS sequential @@ -1319,7 +1330,7 @@ async def rebuild_lineage_graph( # discipline as this file's other orchestrator call sites. raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Lineage rebuild is unavailable: the orchestrator did not respond", + "Lineage rebuild is temporarily unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc return {"edge_count": len(edges)} @@ -1874,7 +1885,7 @@ async def read_similar_voc( if client is None: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "similar VOC inference is unavailable; configure contextual-orchestrator and retry", + "Similar VOC evidence is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) async with pool.acquire() as conn: # Safe SQL: the sole interpolation is the closed eligibility fragment; request and identity values remain asyncpg parameters. @@ -2347,7 +2358,7 @@ async def verify_post_entity_relationships( if not client.available: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Relation verification is unavailable: set SEARXNG_BASE_URL", + "Relation verification is temporarily unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) try: verified = await verify_post_relations_from_pool( @@ -2361,12 +2372,12 @@ async def verify_post_entity_relationships( # provider failure into a clean 503 rather than persisting a miss. raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Relation verification is unavailable: the search provider did not respond", + "Relation verification is temporarily unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Relation verification is unavailable: the search provider did not respond", + "Relation verification is temporarily unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc await publish_activity_event( valkey, @@ -2411,7 +2422,7 @@ async def extract_post_keymen( if not keyman_client.available: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Keymen extraction is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", + "Keymen extraction is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) relationship_client = _entity_relationship_client() async with pool.acquire() as conn: @@ -2441,12 +2452,12 @@ async def extract_post_keymen( except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + "Keymen extraction is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + "Keymen extraction is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc # Live bug (2026-08-19): an organization affiliated ONLY with an # our_side person (our own factory, our own affiliate) got fed @@ -2470,12 +2481,12 @@ async def extract_post_keymen( except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + "Keymen extraction is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + "Keymen extraction is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc async with conn.transaction(): await persist_edges_for_post(conn, post_id) @@ -2625,12 +2636,12 @@ async def evaluate_post( except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Post evaluation is unavailable: contextual-orchestrator returned no complete evidence object", + "Post evaluation is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Post evaluation is unavailable: contextual-orchestrator returned no complete evidence object", + "Post evaluation is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc await publish_activity_event( valkey, @@ -2897,7 +2908,7 @@ def stale_fallback( return stale_fallback("orchestrator_unavailable") raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Post summary is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", + "Post summary is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) context_hints = await _load_post_semantic_hints(conn, post_id) summarize_with_hints = getattr(client, "summarize_with_hints", None) @@ -2915,14 +2926,14 @@ def stale_fallback( return stale_fallback("orchestrator_failure", exc) raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Post summary is unavailable: contextual-orchestrator returned no complete evidence object", + "Post summary is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. if stale is not None: return stale_fallback("orchestrator_unexpected_failure", exc) raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Post summary is unavailable: contextual-orchestrator returned no complete evidence object", + "Post summary is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc try: payload = await persist_post_summary( @@ -2938,7 +2949,7 @@ def stale_fallback( return stale_fallback("summary_persist_failure", exc) raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Post summary is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + "Post summary is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc content_complete = await post_content_is_complete( conn, @@ -2990,6 +3001,7 @@ class ChatRequest(BaseModel): """JSON body for ``POST /api/posts/{post_id}/chat``.""" question: str + conversation_id: UUID | None = None class GlobalAskRequest(BaseModel): @@ -3000,6 +3012,59 @@ class GlobalAskRequest(BaseModel): knowledge_cutoff: str | None = None +async def _persist_post_ask_turn( + conn: asyncpg.Connection, + account: CurrentAccount, + post_id: str, + conversation_id: UUID | None, + question: str, + answer_text: str, + source_post_ids: list[str], + cited_post_ids: list[str], + *, + recover_deleted_conversation: bool = False, +) -> UUID: + """Persist a completed turn after reauthorizing every gathered source. + + A conversation confirmed before answer work can disappear before this + commit. In that narrow case, the completed answer starts a new owned + conversation after the same transactional source reauthorization. + """ + try: + return await persist_post_ask_turn( + conn, + account.user_account_id, + post_id, + conversation_id, + question, + answer_text, + source_post_ids, + cited_post_ids, + can_see_post=lambda row: _can_see_post(account, row), + ) + except PostAskEvidenceChanged as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post chat is temporarily unavailable because authorized evidence changed. Retry the question.", + ) from exc + except PostAskConversationNotFound as exc: + if recover_deleted_conversation and conversation_id is not None: + return await _persist_post_ask_turn( + conn, + account, + post_id, + None, + question, + answer_text, + source_post_ids, + cited_post_ids, + ) + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "This conversation is no longer available. Choose another conversation or start a new one.", + ) from exc + + @app.get("/api/posts/{post_id}/chat") async def read_post_chat( post_id: str, @@ -3044,16 +3109,36 @@ async def chat_about_post( post = await _load_visible_post(post_id, account, pool) post_metadata = build_post_llm_metadata(post_id, post) async with pool.acquire() as conn: + if request.conversation_id is not None and not await post_ask_conversation_exists( + conn, account.user_account_id, post_id, request.conversation_id + ): + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "This conversation is no longer available. Choose another conversation or start a new one.", + ) + conversation_was_confirmed = request.conversation_id is not None stored = await fetch_persisted_chat(conn, post_id, question) if stored is not None: source_ids = [post_id] source_ids.extend(cid for cid in stored["cited_post_ids"] if cid != post_id) + conversation_id = await _persist_post_ask_turn( + conn, + account, + post_id, + request.conversation_id, + question, + stored["answer_text"], + source_ids, + list(stored["cited_post_ids"]), + recover_deleted_conversation=conversation_was_confirmed, + ) return { "post_id": post_id, "answer_text": stored["answer_text"], "cited_post_ids": stored["cited_post_ids"], "cited_posts": stored["cited_posts"], "source_post_ids": source_ids, + "conversation_id": str(conversation_id), } with use_llm_metadata(post_metadata): with traced( @@ -3070,8 +3155,7 @@ async def chat_about_post( ) raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Post chat is temporarily unavailable. " - "Saved evidence is still available.", + _POST_CHAT_RETRY_DETAIL, ) async with pool.acquire() as conn: sources = await gather_chat_sources( @@ -3094,19 +3178,29 @@ async def chat_about_post( record_server_failure("post_chat", exc, outcome="provider_unavailable") raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Post chat is temporarily unavailable. " - "Saved evidence is still available.", + _POST_CHAT_RETRY_DETAIL, ) from exc except Exception as exc: record_server_failure("post_chat", exc, outcome="internal_error") raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Post chat is temporarily unavailable. " - "Saved evidence is still available.", + _POST_CHAT_RETRY_DETAIL, ) from exc cited_ids = list(answer.cited_post_ids) + source_ids = [source.post_id for source in sources] async with pool.acquire() as conn: await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) + conversation_id = await _persist_post_ask_turn( + conn, + account, + post_id, + request.conversation_id, + question, + answer.answer_text, + source_ids, + cited_ids, + recover_deleted_conversation=request.conversation_id is not None, + ) await publish_activity_event( valkey, post_id, @@ -3119,10 +3213,67 @@ async def chat_about_post( "answer_text": answer.answer_text, "cited_post_ids": cited_ids, "cited_posts": cited_post_summaries(sources, cited_ids), - "source_post_ids": [source.post_id for source in sources], + "source_post_ids": source_ids, + "conversation_id": str(conversation_id), } +@app.get("/api/posts/{post_id}/chat/conversations") +async def read_post_chat_conversations( + post_id: str, + limit: int = Query(50, ge=1, le=50), + before_created_at: datetime | None = Query(None), + before_conversation_id: UUID | None = Query(None), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """List this account's saved Ask conversations on one visible post.""" + await _load_visible_post(post_id, account, pool) + if (before_created_at is None) != (before_conversation_id is None): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "before_created_at and before_conversation_id must be provided together", + ) + async with pool.acquire() as conn: + return await list_post_ask_conversations( + conn, + account.user_account_id, + post_id, + limit=limit, + before_created_at=before_created_at, + before_conversation_id=before_conversation_id, + ) + + +@app.get("/api/posts/{post_id}/chat/conversations/{conversation_id}") +async def read_post_chat_conversation( + post_id: str, + conversation_id: UUID, + limit: int = Query(50, ge=1, le=50), + before_turn: int | None = Query(None, ge=1), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Load one owned transcript with currently authorized citations.""" + await _load_visible_post(post_id, account, pool) + async with pool.acquire() as conn: + conversation = await fetch_post_ask_conversation( + conn, + account.user_account_id, + post_id, + conversation_id, + lambda row: _can_see_post(account, row), + turn_limit=limit, + before_turn_ordinal=before_turn, + ) + if conversation is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "This conversation is no longer available. Choose another conversation or start a new one.", + ) + return conversation + + @app.post("/api/ask", status_code=status.HTTP_202_ACCEPTED) async def ask_agent( request: GlobalAskRequest, @@ -3383,7 +3534,7 @@ async def derive_post_commitment( if not client.available: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Commitment derivation is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", + "Commitment derivation is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) async with pool.acquire() as conn: body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) @@ -3404,12 +3555,12 @@ async def derive_post_commitment( except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Commitment derivation is unavailable: contextual-orchestrator returned no complete evidence object", + "Commitment derivation is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - "Commitment derivation is unavailable: contextual-orchestrator returned no complete evidence object", + "Commitment derivation is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", ) from exc if not commitment.has_commitment: return {"post_id": str(post["post_id"]), "has_commitment": False, "ticket": None} @@ -3461,8 +3612,8 @@ class CreateAnalysisRunRequest(BaseModel): """JSON body for ``POST /api/analysis-runs``. Omitting ``corporate_entity_id`` uses the account's sole affiliation. - Only ``analysis_run_lineage`` is accepted. Reconstruction and TEPP - execution stay later slices; this write records Pending lineage only. + Lineage, measurement, and topic-lineage requests are accepted. Execution + stays on the start/outbox path; this write records Pending only. """ run_kind_code: str = "analysis_run_lineage" @@ -3478,12 +3629,12 @@ async def create_analysis_run( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """Record a Pending lineage run on an authorized cutoff capture. + """Record a Pending analysis request on an authorized cutoff capture. post_read is enough: the caller requests a run of a corp they - already walk. TEPP and period-report kinds are 422 so this path - cannot invent a measurement. Hidden scopes 404. A matching - idempotent retry returns the same run. + already walk. Measurement work remains Pending until the TEPP start + path submits it; period-report stays on its rebuild path. Hidden scopes + 404. A matching idempotent retry returns the same run. """ _require_post_read(account) async with pool.acquire() as conn: diff --git a/backend/app/post_ask_history.py b/backend/app/post_ask_history.py new file mode 100644 index 000000000..87a8dfd84 --- /dev/null +++ b/backend/app/post_ask_history.py @@ -0,0 +1,407 @@ +"""Account-owned persistence for Ask conversations on one visible post. + +ADR 0235 reuses the ADR 0126 list/select/new contract with a required +post_id scope. Conversation ids are never Global Ask session ids. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from datetime import datetime +from typing import Any +from uuid import UUID, uuid4 + +import asyncpg + +from .post_eligibility import SOURCE_POST_ELIGIBILITY_SQL + + +class PostAskConversationNotFound(LookupError): + """The requested conversation is absent, on another post, or another account.""" + + +class PostAskEvidenceChanged(RuntimeError): + """A gathered source became unauthorized before the new turn could commit.""" + + +async def conversation_exists( + conn: asyncpg.Connection, + user_account_id: str, + post_id: str, + conversation_id: UUID, +) -> bool: + """Return whether this account owns the conversation on ``post_id``.""" + return bool( + await conn.fetchval( + """ + select exists( + select 1 + from post_ask_session + where post_ask_session_id = $1 + and user_account_id = $2 + and post_id = $3 + ) + """, + conversation_id, + user_account_id, + post_id, + ) + ) + + +async def list_conversations( + conn: asyncpg.Connection, + user_account_id: str, + post_id: str, + *, + limit: int = 50, + before_created_at: datetime | None = None, + before_conversation_id: UUID | None = None, +) -> dict[str, Any]: + """Return this account's conversations on ``post_id``, newest-created first.""" + if (before_created_at is None) != (before_conversation_id is None): + raise ValueError("before_created_at and before_conversation_id must be provided together") + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + """ + select session.post_ask_session_id, + (select left(turn.question_text, 80) + from post_ask_turn turn + where turn.post_ask_session_id = session.post_ask_session_id + order by turn.turn_ordinal + limit 1) as conversation_title, + session.created_at, + session.updated_at, + count(turn.turn_ordinal)::int as turn_count + from post_ask_session session + left join post_ask_turn turn + on turn.post_ask_session_id = session.post_ask_session_id + where session.user_account_id = $1 + and session.post_id = $2 + and ( + $3 is null + or $4 is null + or session.created_at < $3 + or (session.created_at = $3 and session.post_ask_session_id < $4) + ) + group by session.post_ask_session_id, session.created_at, session.updated_at + order by session.created_at desc, session.post_ask_session_id desc + limit $5 + """, + user_account_id, + post_id, + before_created_at, + before_conversation_id, + limit + 1, + ) + page_rows = rows[:limit] + next_cursor = None + if len(rows) > limit and page_rows: + last = page_rows[-1] + next_cursor = { + "created_at": last["created_at"], + "conversation_id": str(last["post_ask_session_id"]), + } + return { + "conversations": [ + { + "conversation_id": str(row["post_ask_session_id"]), + "title": row["conversation_title"], + "updated_at": row["updated_at"], + "turn_count": row["turn_count"], + } + for row in page_rows + ], + "next_cursor": next_cursor, + } + + +async def _visible_post_ids_batch( + conn: asyncpg.Connection, + conversation_id: UUID, + turn_ordinals: list[int], + can_see_post: Callable[[asyncpg.Record], bool], + *, + source: bool, +) -> dict[int, tuple[list[str], dict[str, asyncpg.Record]]]: + """Reauthorize every turn's sources or citations in one query. + + Fetches all `turn_ordinals` at once instead of one query per turn, so a + conversation's query count stays constant regardless of how many turns + it has. Returns each turn's currently-visible post ids and rows, keyed + by turn ordinal; a turn with no visible rows still gets an empty entry. + """ + by_turn: dict[int, tuple[list[str], dict[str, asyncpg.Record]]] = { + ordinal: ([], {}) for ordinal in turn_ordinals + } + if not turn_ordinals: + return by_turn + if source: + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select relation.turn_ordinal as turn_ordinal, + relation.source_post_id::text as post_id, relation.source_ordinal as ordinal, + post.post_title, post.visibility_code, post.corporate_entity_id, + post.process_unit_id, + post.author_account_id, post.source_detail_state_code + from post_ask_turn_source relation + join source_post post on post.post_id = relation.source_post_id + where relation.post_ask_session_id = $1 + and relation.turn_ordinal = any($2::int[]) + and ({SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}) + order by relation.turn_ordinal, relation.source_ordinal + """, + conversation_id, + turn_ordinals, + ) + else: + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select relation.turn_ordinal as turn_ordinal, + relation.cited_post_id::text as post_id, relation.citation_ordinal as ordinal, + post.post_title, post.visibility_code, post.corporate_entity_id, + post.process_unit_id, + post.author_account_id, post.source_detail_state_code + from post_ask_turn_citation relation + join source_post post on post.post_id = relation.cited_post_id + where relation.post_ask_session_id = $1 + and relation.turn_ordinal = any($2::int[]) + and ({SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}) + order by relation.turn_ordinal, relation.citation_ordinal + """, + conversation_id, + turn_ordinals, + ) + for row in rows: + if not can_see_post(row): + continue + ordinal = int(row["turn_ordinal"]) + post_id = str(row["post_id"]) + ids, id_map = by_turn[ordinal] + ids.append(post_id) + id_map[post_id] = row + return by_turn + + +async def fetch_conversation( + conn: asyncpg.Connection, + user_account_id: str, + post_id: str, + conversation_id: UUID, + can_see_post: Callable[[asyncpg.Record], bool], + *, + turn_limit: int = 50, + before_turn_ordinal: int | None = None, +) -> dict[str, Any] | None: + """Return one owned transcript with currently authorized citations.""" + header = await conn.fetchrow( + """ + select post_ask_session_id + from post_ask_session + where post_ask_session_id = $1 + and user_account_id = $2 + and post_id = $3 + """, + conversation_id, + user_account_id, + post_id, + ) + if header is None: + return None + + title_question = await conn.fetchval( + """ + select question_text + from post_ask_turn + where post_ask_session_id = $1 + order by turn_ordinal + limit 1 + """, + conversation_id, + ) + if before_turn_ordinal is None: + turns = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + """ + select turn_ordinal, question_text, answer_text + from post_ask_turn + where post_ask_session_id = $1 + order by turn_ordinal desc + limit $2 + """, + conversation_id, + turn_limit + 1, + ) + else: + turns = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + """ + select turn_ordinal, question_text, answer_text + from post_ask_turn + where post_ask_session_id = $1 + and turn_ordinal < $2 + order by turn_ordinal desc + limit $3 + """, + conversation_id, + before_turn_ordinal, + turn_limit + 1, + ) + has_older = len(turns) > turn_limit + turns = list(turns[:turn_limit]) + turns.reverse() + ordinals = [int(turn["turn_ordinal"]) for turn in turns] + sources_by_turn = await _visible_post_ids_batch( + conn, conversation_id, ordinals, can_see_post, source=True + ) + citations_by_turn = await _visible_post_ids_batch( + conn, conversation_id, ordinals, can_see_post, source=False + ) + exchanges: list[dict[str, Any]] = [] + for turn in turns: + ordinal = int(turn["turn_ordinal"]) + source_ids, _ = sources_by_turn[ordinal] + cited_ids, cited_rows = citations_by_turn[ordinal] + exchanges.append( + { + "turn_id": f"{conversation_id}:{ordinal}", + "question_text": turn["question_text"], + "answer_text": turn["answer_text"], + "cited_post_ids": cited_ids, + "cited_posts": [ + {"post_id": post_id_value, "post_title": cited_rows[post_id_value]["post_title"]} + for post_id_value in cited_ids + ], + "source_post_ids": source_ids, + } + ) + title = title_question[:80] if title_question else None + return { + "conversation_id": str(header["post_ask_session_id"]), + "title": title, + "exchanges": exchanges, + "older_cursor": str(turns[0]["turn_ordinal"]) if has_older and turns else None, + } + + +async def _ensure_sources_visible( + conn: asyncpg.Connection, + source_post_ids: list[str], + can_see_post: Callable[[asyncpg.Record], bool], +) -> None: + """Lock and re-authorize every source before dependent rows are inserted.""" + # Safe SQL: the only interpolation is the repository-owned eligibility + # expression; every request value remains an asyncpg parameter. + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select post.post_id::text as post_id, post.post_title, + post.visibility_code, post.corporate_entity_id, + post.process_unit_id, + post.author_account_id, post.source_detail_state_code + from source_post post + where post.post_id = any($1::uuid[]) + and ({SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}) + for share of post + """, + source_post_ids, + ) + if len(rows) != len(source_post_ids) or any( + not can_see_post(row) for row in rows + ): + raise PostAskEvidenceChanged + + +async def persist_turn( + conn: asyncpg.Connection, + user_account_id: str, + post_id: str, + conversation_id: UUID | None, + question: str, + answer_text: str, + source_post_ids: Iterable[str], + cited_post_ids: Iterable[str], + can_see_post: Callable[[asyncpg.Record], bool] | None = None, +) -> UUID: + """Append one completed turn and return the conversation id.""" + source_ids = list(dict.fromkeys(str(post_id_value) for post_id_value in source_post_ids)) + source_set = set(source_ids) + cited_ids = list( + dict.fromkeys(str(post_id_value) for post_id_value in cited_post_ids if str(post_id_value) in source_set) + ) + async with conn.transaction(): + if conversation_id is None: + if can_see_post is not None: + await _ensure_sources_visible(conn, source_ids, can_see_post) + conversation_id = uuid4() + await conn.execute( + """ + insert into post_ask_session (post_ask_session_id, post_id, user_account_id) + values ($1, $2, $3) + """, + conversation_id, + post_id, + user_account_id, + ) + else: + conversation = await conn.fetchrow( + """ + select post_ask_session_id + from post_ask_session + where post_ask_session_id = $1 + and user_account_id = $2 + and post_id = $3 + for update + """, + conversation_id, + user_account_id, + post_id, + ) + if conversation is None: + raise PostAskConversationNotFound + if can_see_post is not None: + await _ensure_sources_visible(conn, source_ids, can_see_post) + + ordinal = int( + await conn.fetchval( + "select coalesce(max(turn_ordinal), 0) + 1 from post_ask_turn where post_ask_session_id = $1", + conversation_id, + ) + ) + await conn.execute( + """ + insert into post_ask_turn + (post_ask_session_id, turn_ordinal, question_text, answer_text) + values ($1, $2, $3, $4) + """, + conversation_id, + ordinal, + question, + answer_text, + ) + for source_ordinal, source_post_id in enumerate(source_ids): + await conn.execute( + """ + insert into post_ask_turn_source + (post_ask_session_id, turn_ordinal, source_ordinal, source_post_id) + values ($1, $2, $3, $4) + """, + conversation_id, + ordinal, + source_ordinal, + source_post_id, + ) + for citation_ordinal, cited_post_id in enumerate(cited_ids): + await conn.execute( + """ + insert into post_ask_turn_citation + (post_ask_session_id, turn_ordinal, citation_ordinal, cited_post_id) + values ($1, $2, $3, $4) + """, + conversation_id, + ordinal, + citation_ordinal, + cited_post_id, + ) + await conn.execute( + "update post_ask_session set updated_at = now() where post_ask_session_id = $1", + conversation_id, + ) + assert conversation_id is not None + return conversation_id diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 4a208c13c..13f51160f 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -344,13 +344,14 @@ async def gather_chat_sources( if vision_client is None: vision_client = NullImageContentClient() - this_post = await conn.fetchrow( + this_post = await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli "select post_id, post_title, post_body, source_system_code, source_record_key, " "source_author_code, source_author_name, source_company_code, source_company_name, " "source_process_unit_code, source_process_unit_name, " "source_sales_pool_code, source_sales_pool_name, " "source_customer_code, source_customer_name, source_project_code, " - "source_project_name from source_post where post_id = $1", + "source_project_name from source_post where post_id = $1 and " + f"{SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}", post_id, ) if this_post is None: @@ -380,14 +381,15 @@ async def gather_chat_sources( ) ] - rows = await conn.fetch( + rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli "select post_id, post_title, post_body, visibility_code, corporate_entity_id, process_unit_id, " "source_system_code, source_record_key, source_author_code, source_author_name, " "source_company_code, source_company_name, source_process_unit_code, " "source_process_unit_name, source_sales_pool_code, source_sales_pool_name, " "source_customer_code, source_customer_name, " "source_project_code, source_project_name " - "from source_post where post_id = any($1::uuid[]) " + "from source_post where post_id = any($1::uuid[]) and " + f"{SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')} " "order by array_position($1::uuid[], post_id) limit $2", candidate_ids, _POST_CHAT_CANDIDATE_LIMIT, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 892c8231a..02eaa4d40 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -231,6 +231,11 @@ / "migrations" / "0183_source_post_event_occurred_at.sql" ) +_POST_ASK_HISTORY_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0223_post_ask_conversation_history.sql" +) def _postgres_available() -> bool: @@ -392,6 +397,7 @@ def seeded_db(demo_analyst_token): cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text()) + cur.execute(_POST_ASK_HISTORY_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -1054,9 +1060,10 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( "idempotency_key": "buyer-create-tepp", }, ) - assert tepp.status_code == 422 - assert "invent a measurement" in tepp.json()["detail"] - assert "theta" not in tepp.json()["detail"].lower() + assert tepp.status_code == 201 + assert tepp.json()["status_label"] == "Pending" + assert tepp.json()["scope_corporate_entity_id"] == seeded_db["own_corp_id"] + assert "theta" not in str(tepp.json()).lower() report = client.post( "/api/analysis-runs", @@ -1213,63 +1220,9 @@ def test_start_analysis_run_recovers_the_a100_fork( "idempotency_key": "buyer-start-tepp-2026-w07", }, ) - assert tepp_create.status_code == 422 - assert "invent a measurement" in tepp_create.json()["detail"] - - admin_conn = psycopg2.connect(seeded_db["dsn"]) - admin_conn.autocommit = True - try: - with admin_conn.cursor() as cur: - cur.execute( - "select requested_by_account_id from analysis_run where analysis_run_id = %s", - (run_id,), - ) - requester_id = cur.fetchone()[0] - cur.execute( - """ - insert into analysis_source_snapshot - (snapshot_sha256, source_contract_version, - maximum_available_time, captured_at) - values (%s, 'source-contract-v1', - '2026-02-15T00:00:00Z', '2026-02-15T00:05:00Z') - returning analysis_source_snapshot_id - """, - ("f" * 64,), - ) - tepp_snapshot_id = cur.fetchone()[0] - cur.execute( - """ - insert into analysis_run - (analysis_source_snapshot_id, run_kind_code, idempotency_key, - requested_by_account_id, knowledge_cutoff, - configuration_schema_version, configuration_sha256, - code_revision_sha, requested_at) - values (%s, 'analysis_run_tepp', 'buyer-start-tepp-seeded', - %s, '2026-02-15T00:00:00Z', 'tepp-run-v1', %s, %s, - '2026-02-15T12:30:00Z') - returning analysis_run_id - """, - (tepp_snapshot_id, requester_id, "a" * 64, "b" * 40), - ) - tepp_run_id = str(cur.fetchone()[0]) - cur.execute( - """ - insert into analysis_run_scope - (analysis_run_id, scope_kind_code, corporate_entity_id) - values (%s, 'analysis_scope_corporate_entity', %s) - """, - (tepp_run_id, seeded_db["own_corp_id"]), - ) - cur.execute( - """ - insert into analysis_run_status_event - (analysis_run_id, status_ordinal, status_code, occurred_at) - values (%s, 1, 'analysis_status_pending', '2026-02-15T12:31:00Z') - """, - (tepp_run_id,), - ) - finally: - admin_conn.close() + assert tepp_create.status_code == 201 + assert tepp_create.json()["status_label"] == "Pending" + tepp_run_id = tepp_create.json()["analysis_run_id"] measured = client.post( f"/api/analysis-runs/{tepp_run_id}/start", @@ -3928,7 +3881,8 @@ def answer(self, question: str, sources) -> None: assert response.status_code == 503 assert response.json()["detail"] == ( - "Post chat is temporarily unavailable. Saved evidence is still available." + "Post chat is temporarily unavailable. Review the saved evidence, then " + "retry in a moment. If this continues, contact your workspace administrator." ) @@ -4017,6 +3971,7 @@ def extract(self, post_title: str, post_body: str) -> object: assert response.status_code == 503 assert "raw-keyman-provider-secret" not in response.text + assert response.json()["detail"].endswith("contact your workspace administrator.") def test_evaluation_provider_error_does_not_leak_raw_error( @@ -4042,6 +3997,7 @@ def evaluate(self, post_title: str, post_body: str) -> object: assert response.status_code == 503 assert "raw-evaluation-provider-secret" not in response.text + assert response.json()["detail"].endswith("contact your workspace administrator.") def test_commitment_provider_error_does_not_leak_raw_error( @@ -4067,6 +4023,7 @@ def extract(self, post_title: str, post_body: str, reference_date: str) -> objec assert response.status_code == 503 assert "raw-commitment-provider-secret" not in response.text + assert response.json()["detail"].endswith("contact your workspace administrator.") def test_summary_enrichment_provider_error_does_not_leak_raw_error( @@ -4094,6 +4051,7 @@ async def _fail_persist(*args, **kwargs): assert response.status_code == 503 assert "raw-summary-provider-secret" not in response.text + assert response.json()["detail"].endswith("contact your workspace administrator.") def test_evaluate_is_unavailable_without_orchestrator(client, demo_analyst_token, seeded_db) -> None: diff --git a/docs/adr/0017-authorized-analysis-run-create.md b/docs/adr/0017-authorized-analysis-run-create.md index 81841c469..556fa10d1 100644 --- a/docs/adr/0017-authorized-analysis-run-create.md +++ b/docs/adr/0017-authorized-analysis-run-create.md @@ -1,4 +1,4 @@ -# ADR 0017 — Operators request a pending lineage run on an authorized capture +# ADR 0017 — Operators request a pending analysis run on an authorized capture **Decision status:** Accepted on this active PR; not protected-main truth until merge **Date:** 2026-08-16 @@ -14,9 +14,10 @@ ADR 0013 already required a transaction that creates snapshot, counts, run, scope, and the first status atomically. Follow-up 3 (outbox / worker) still owns reconstruction and live TEPP execution. -`#125` landed that write and also accepted a TEPP kind. A Pending TEPP -row that never called `tepp_client` is a fabricated measurement request. -This decision keeps the live cutoff capture and closes that hole. +`#125` landed the write before the durable TEPP start path existed. ADR 0022 +now submits Pending measurement work through `tepp_client`, so refusing to +record the request no longer protects the measurement boundary; it only leaves +the customer without a recovery action. ## Decision @@ -24,9 +25,10 @@ This decision keeps the live cutoff capture and closes that hole. - `post_read` is enough. The caller may only cover a corporate entity they already walk. An unaffiliated corp is 404, not 403. -- Only `analysis_run_lineage` is accepted. TEPP stays a `tepp_client` - wire path (`tepp_not_available` / `tepp_result_not_persisted`). Period - reports stay on the Reports panel rebuild. +- Lineage, TEPP measurement, and topic-lineage requests are accepted. Their + first status is Pending; TEPP execution remains a `tepp_client` wire path + (`tepp_not_available` / `tepp_result_not_persisted`). Period reports stay on + the Reports panel rebuild. - The capture digest hashes scope, entity, cutoff, and authorized post ids — never a post body, DSN, source SQL, or a theta. - The write inserts snapshot, aggregate counts, frozen @@ -47,7 +49,7 @@ sequenceDiagram participant API participant Registry Operator->>API: POST /api/analysis-runs - alt TEPP, report, or unknown kind + alt report or unknown kind API-->>Operator: 422 next-action (no registry write) else same account+key+digest API->>Registry: compare configuration digest @@ -55,7 +57,7 @@ sequenceDiagram API-->>Operator: 201 replay else same key, different digest API-->>Operator: 409 conflict - else lineage kind, new key + else supported kind, new key API->>Registry: capture authorized cutoff bag Registry->>Registry: snapshot + counts + run + scope + pending API-->>Operator: 201 Pending row @@ -64,14 +66,14 @@ sequenceDiagram The home panel's **Request a lineage reconstruction** button stays disabled until `GET /api/me` returns affiliated corps, then records -that Pending row for the chosen entity. Only a failed TEPP row -mentions the measurement service. Failed TEPP is terminal on this -write: create does not invent a Pending TEPP row. +that Pending row for the chosen entity. A Failed measurement remains terminal; +its retry action creates a new current-snapshot Pending row and starts it +through ADR 0022 rather than mutating history. ## Consequences -- Demo Analyst can request a new Pending Demo Corp lineage run after - `make seed` without inventing a measurement. +- An authorized analyst can request a new Pending lineage, measurement, or + topic-lineage run without claiming a result. - A multi-affiliation account sees the corp picker before the Request button enables, then chooses the corp before clicking. - `POST /api/analysis-runs/{id}/start` then reconstructs that frozen bag diff --git a/docs/adr/0022-authorized-tepp-start.md b/docs/adr/0022-authorized-tepp-start.md index 29012f888..309eafa7c 100644 --- a/docs/adr/0022-authorized-tepp-start.md +++ b/docs/adr/0022-authorized-tepp-start.md @@ -10,12 +10,10 @@ through the published client; persistable result remains later) ## Context ADR 0021 starts a Pending lineage reconstruction in-process. The same -`POST /api/analysis-runs/{id}/start` path returned 422 for TEPP so it -could not invent a theta. Create already records a Pending TEPP run. -Seed already records a Failed TEPP run through `tepp_client`. The Failed -row tells the operator to connect the measurement service and re-run, -but Failed is terminal and there was no start path that called -`tepp_client`. +`POST /api/analysis-runs/{id}/start` path originally returned 422 for TEPP so +it could not invent a theta. Start now submits a Pending request through +`tepp_client`, but a Failed run remained terminal with no product action that +could request and start a new measurement after the authority recovered. A buyer who connects a live TEPP transport still could not submit the frozen snapshot. A 422 that says "do not invent a measurement" is @@ -49,12 +47,16 @@ authorized transaction: or refused, or Failed / `tepp_result_not_persisted` when TEPP accepts an envelope this product cannot store yet. +`POST /api/analysis-runs` accepts lineage, TEPP measurement, and topic-lineage +requests. A Pending row is an immutable work request, not a calibrated-result +claim. Retry measurement creates a new current-snapshot run and submits it +through the same outbox and `tepp_client` path; the Failed source run remains +terminal and auditable. + Succeeded TEPP stays later. This slice does not persist a local psychometric substitute, does not call contextual-orchestrator as TEPP, and does not stamp Succeeded from an `accepted` envelope. Failed remains -terminal. `POST /api/analysis-runs` is lineage-only (ADR 0017) and does -not invent a Pending TEPP row. The operator connects a TEPP transport -from the Failed row, then starts that same measurement. +terminal and no local theta is created. ```mermaid sequenceDiagram @@ -84,11 +86,13 @@ item instead of rolling back to Pending. ## Consequences -Demo Analyst can request a TEPP run, start it, and see Failed / +An authorized analyst can request a TEPP run, start it, and see Failed / `tepp_not_available` until a live transport is configured. Connecting `TEPP_TRANSPORT_URL` submits the same published payload and modular-consumer headers. TEPP PR #155 must merge and its service must be deployed before this -transport can accept a run. An accepted +transport can accept a run. After recovery, Retry measurement creates and +starts a new immutable current-snapshot run instead of mutating the Failed +history. An accepted envelope still does not become a calibrated result. Do not invent a theta. diff --git a/docs/adr/0050-seed-period-report-analysis-run.md b/docs/adr/0050-seed-period-report-analysis-run.md index 0fff6fe80..62242e5c7 100644 --- a/docs/adr/0050-seed-period-report-analysis-run.md +++ b/docs/adr/0050-seed-period-report-analysis-run.md @@ -37,7 +37,8 @@ on a path that is not allowed to (ADR 0021 / ADR 0022 / ADR 0023). body. - Home next-action copy for a Succeeded report stays empty. Failed report fixtures still say rebuild the period report. -- `POST /api/analysis-runs` stays lineage-only (ADR 0017). +- `POST /api/analysis-runs` accepts lineage and TEPP-backed work but still + rejects period-report (ADR 0017). `POST /api/analysis-runs/{id}/start` stays 422 for this kind. This slice does not add a Request period-report button, does not enqueue outbox work, and does not call TEPP. diff --git a/docs/adr/0132-tepp-topic-lineage-consumption-boundary.md b/docs/adr/0132-tepp-topic-lineage-consumption-boundary.md index f4fb00d28..3b3cc6e31 100644 --- a/docs/adr/0132-tepp-topic-lineage-consumption-boundary.md +++ b/docs/adr/0132-tepp-topic-lineage-consumption-boundary.md @@ -60,8 +60,8 @@ substitute" ADR 0084 and the tepp-readiness discipline already forbid. versioned topic-identity/CHRONOS-status envelope, appends Failed with a machine-readable reason (`tepp_not_available` / `tepp_topic_contract_unavailable`), mirroring ADR 0022. Failed is - terminal; the operator reconnects TEPP and retries the existing Failed row - through `POST /api/analysis-runs/{id}/start`. + terminal; after the authority recovers, LineageWeave creates a new Pending + current-snapshot run and submits it through the normal start/outbox path. 4. When TEPP does publish the topic-identity and CHRONOS-status envelope, LineageWeave persists it into a new topic-identity-thread projection (3NF, two-word snake_case, partitioned by corporate-entity + observed @@ -88,9 +88,8 @@ sequenceDiagram participant TeppClient participant Registry Operator->>API: POST /api/analysis-runs (kind=topic_lineage) - API-->>Operator: 422; no Pending topic-lineage row is created - Note over Operator,Registry: Existing Failed rows come from the governed seed/import boundary - Operator->>API: Connect TEPP; POST /api/analysis-runs/{id}/start + API-->>Operator: Pending topic-lineage request + Operator->>API: POST /api/analysis-runs/{id}/start Registry->>Registry: Running API->>TeppClient: TopicLineageRequest v1 (TRSL-TM + CHRONOS/TDT) alt TeppNotAvailable @@ -101,6 +100,7 @@ sequenceDiagram Registry->>Registry: Succeeded; persist topic-identity threads + CHRONOS status end API-->>Operator: run status + evidence/inference/prediction detail + Note over Operator,Registry: Failed stays terminal; Retry creates and starts a new run ``` ### Implementation note: the status-mark primitive ships ahead of the wiring diff --git a/docs/adr/0207-repository-case-ontology-namespace-canonical.md b/docs/adr/0207-repository-case-ontology-namespace-canonical.md index 4b70fb45e..1f2b55296 100644 --- a/docs/adr/0207-repository-case-ontology-namespace-canonical.md +++ b/docs/adr/0207-repository-case-ontology-namespace-canonical.md @@ -4,6 +4,7 @@ **Date:** 2026-08-25 **Supersedes:** [ADR 0157](0157-public-ontology-namespace-identity.md) **Resolves:** [#372](https://github.com/ContextualWisdomLab/LineageWeave/issues/372) +**Amended by:** [ADR 0236](0236-legacy-ontology-namespace-publication.md) ## Context @@ -46,14 +47,15 @@ fail loudly instead of silently polluting downstream graphs. `https://contextualwisdomlab.github.io/LineageWeave/ontology#`. New runtime values, RDF exports, database rows, examples, API payloads, and generated Pages artifacts mint only repository-case term IRIs. -2. The lowercase namespace is now the deprecated compatibility namespace. It - remains dereferenceable through the published compatibility vocabulary and - is never reused for different meanings. -3. The publication slice serves both namespace documents with `200 OK`. The - repository-case document is authoritative; the lowercase document identifies +2. The lowercase namespace is now the deprecated compatibility namespace and + is never reused for different meanings. ADR 0236 clarifies that its current + `404` path is not described as dereferenceable. +3. The publication slice serves the repository-case namespace document and + its `namespace-compatibility.ttl` mapping document with `200 OK`. The + repository-case document is authoritative; the mapping document identifies it via `dcterms:isReplacedBy`, carries `owl:deprecated true`, and holds only - validated mappings. A redirect alone remains insufficient, per ADR 0157's - reasoning, which applies symmetrically. + validated mappings. It does not make the lowercase namespace path a served + document (ADR 0236). 4. Compatibility mappings are generated between the two parsed graphs and emitted only when local-name uniqueness, term kind, and defining semantics match: class-to-class `owl:equivalentClass`; property-to-same-kind @@ -128,8 +130,8 @@ interoperability defect. - One canonical namespace aligned with the served Pages path; new producers are unambiguous. -- The lowercase compatibility vocabulary remains resolvable indefinitely; - existing serialized graphs keep resolving through validated mappings. +- The compatibility mapping document remains resolvable indefinitely; + existing serialized graphs can translate through validated mappings. - Stored-value migration runs through `scripts/migrate_legacy_namespace.py` with its existing dry-run/refusal discipline, direction reversed. - Runtime constants, Turtle/JSON-LD/N-Triples, support profile, API and @@ -139,8 +141,8 @@ interoperability defect. ## Verification -- Exact `200` responses for both namespace documents and representative - fragments, with the repository-case document identified as canonical. +- Exact `200` responses for the canonical namespace and compatibility mapping + documents, plus explicit `404` evidence for the unserved lowercase path. - RDF graph-isomorphism and term-kind tests for every emitted mapping; no duplicate local fragments across namespaces. - Consumer fixtures prove old lowercase graphs still resolve and new diff --git a/docs/adr/0218-current-contract-mcp-global-ask.md b/docs/adr/0218-current-contract-mcp-global-ask.md index 2b2e15f60..624f2d87f 100644 --- a/docs/adr/0218-current-contract-mcp-global-ask.md +++ b/docs/adr/0218-current-contract-mcp-global-ask.md @@ -87,4 +87,3 @@ https://doi.org/10.17487/RFC9700 Model Context Protocol. (2025). *Transports: Streamable HTTP* (Specification 2025-06-18). https://modelcontextprotocol.io/specification/2025-06-18/basic/transports - diff --git a/docs/adr/0222-project-nodes-in-ontology-neighborhood.md b/docs/adr/0222-project-nodes-in-ontology-neighborhood.md index 900b76e8e..597bf06da 100644 --- a/docs/adr/0222-project-nodes-in-ontology-neighborhood.md +++ b/docs/adr/0222-project-nodes-in-ontology-neighborhood.md @@ -1,8 +1,8 @@ # ADR 0222: Project nodes in the ontology neighborhood -**Status:** Accepted -**Date:** 2026-08-26 -**Extends:** [ADR 0036](0036-semantic-project-and-keyman-evidence.md), [ADR 0184](0184-ontology-provenance-explorer.md) +**Status:** Accepted +**Date:** 2026-08-26 +**Extends:** [ADR 0036](0036-semantic-project-and-keyman-evidence.md), [ADR 0184](0184-ontology-provenance-explorer.md) **Figma:** File ID `1Su3lDRmiZdcUs47t1QwIX` ## Context diff --git a/docs/adr/0230-pnpm-build-script-allowlist.md b/docs/adr/0230-pnpm-build-script-allowlist.md index 8433fb4e6..97d84ef18 100644 --- a/docs/adr/0230-pnpm-build-script-allowlist.md +++ b/docs/adr/0230-pnpm-build-script-allowlist.md @@ -1,6 +1,6 @@ # ADR 0230: pnpm build-script allowlist -**Status:** Accepted +**Status:** Accepted **Date:** 2026-08-26 ## Context diff --git a/docs/adr/0231-external-lineage-arithmetic-authority.md b/docs/adr/0231-external-lineage-arithmetic-authority.md index c520cbeb5..6eb20c793 100644 --- a/docs/adr/0231-external-lineage-arithmetic-authority.md +++ b/docs/adr/0231-external-lineage-arithmetic-authority.md @@ -1,6 +1,6 @@ # ADR 0231: External lineage arithmetic authority -**Status:** Accepted +**Status:** Accepted **Date:** 2026-08-26 ## Context diff --git a/docs/adr/0235-post-ask-conversation-history.md b/docs/adr/0235-post-ask-conversation-history.md new file mode 100644 index 000000000..5c0c5c3ac --- /dev/null +++ b/docs/adr/0235-post-ask-conversation-history.md @@ -0,0 +1,95 @@ +# ADR 0235: Persisted per-post Ask conversation history + +* Status: Accepted +* Date: 2026-08-23 +* Figma: File ID `1Su3lDRmiZdcUs47t1QwIX` +* Related: [0090](0090-global-ask-lineage-timeline-expansion.md), [0118](0118-uiux-standard-guide-v3-design-overhaul.md) + +## Context + +The post popup **Ask about this lineage** surface stored one shared +`post_chat_result` row per +`(post_id, question_norm)` and rendered a linear transcript plus re-ask +chips. Leaving the popup, switching questions, or starting a new thread +could not reopen an earlier account-owned conversation on that post. + +That is a different metaphor from the conversation-history sidebar the +reader already uses on Ask Agent. Seeded fixture answers remain the +orchestrator-off demo cache; they are not a substitute for account-owned +history. + +## Decision + +Persist per-post Ask conversations under the authenticated `user_account` +and the visible `source_post`: an explicit +conversation id, list/select/new, and visibility-filtered citations on +read. Do not represent this id as a Global Ask session id or as a fake +post-scoped orchestrator session id. + +Normalized tables: + +* `post_ask_session` — multiple conversations may belong to the same account + and post +* `post_ask_turn` — ordered questions and answers +* `post_ask_turn_citation` / `post_ask_turn_source` — cited and retrieved + posts + +The composite index `(user_account_id, post_id, created_at desc)` leads +with the account so a hot post cannot concentrate list traffic on one +partition key. Conversation pagination uses that immutable creation key; +`updated_at` remains display metadata but cannot move a row across a cursor +while a reader loads later pages. A turn is written only after a complete answer exists +(seeded cache hit or orchestrator object). History reads re-apply current +post visibility before returning titles or citations. + +`post_chat_result` stays the post-level seeded/cache store used when the +orchestrator is off. Account history is additional, not a replacement. + +## Consequences + +* A reader can list saved questions on a post, reopen one and see its + turns, and start a new conversation without losing the list. +* A user cannot read another account's post conversation by changing a + UUID, and cannot load a conversation against a different post id. +* Revoked post visibility removes that post's citation projection; the + stored answer remains account-owned transcript data. +* Hard deletion is a separate data-erasure boundary and cascades the post's + conversations. Callers must use visibility revocation, not hard deletion, + when the source record must remain retained but inaccessible. +* TEPP topic modeling of how many posts can connect, and how many + lineages form under temporal precedence, remains deferred. +* Reauthorization for a conversation's turns is batched + (`_visible_post_ids_batch`, one query per relation type per page instead + of per turn), preventing transcript length from creating an N+1 query path. +* `persist_turn` now row-share-locks and re-authorizes every + `source_post_id` inside its own commit transaction + (`_ensure_sources_visible`, raising `PostAskEvidenceChanged` -> 503). + Persisted citations remain a subset of those authorized sources, so a + cited or non-cited source that loses authorization between source-gathering + and commit aborts the whole turn. +* A selected conversation is ownership-scoped before answer work begins. If + that confirmed conversation is deleted before the commit transaction, the + answer is saved once as a new owned conversation after a fresh transactional + source reauthorization. A never-confirmed or out-of-scope conversation still + returns 404; the implementation never holds a database lock across answer + generation. + +## Implementation Plan + +* **Affected paths:** `migrations/0223_post_ask_conversation_history.sql`, + `backend/app/post_ask_history.py`, `backend/app/main.py`, + `frontend/src/api.ts`, `frontend/src/App.tsx` (`ChatPanel`), + `frontend/src/i18n.ts`, `tests/test_post_ask_history.py`, + `frontend/src/ChatPanel.test.tsx` +* **Pattern:** require `post_id` and `user_account_id` scope on every query. +* **Verification:** backend tests drive `list_conversations`, + `fetch_conversation`, and `persist_turn`. Frontend tests click New + conversation, select a saved conversation, and assert the matching turns. + +## References — APA 7th + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C +Recommendation). https://www.w3.org/TR/prov-o/ + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines +(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ diff --git a/docs/adr/0236-legacy-ontology-namespace-publication.md b/docs/adr/0236-legacy-ontology-namespace-publication.md new file mode 100644 index 000000000..12ea77f4e --- /dev/null +++ b/docs/adr/0236-legacy-ontology-namespace-publication.md @@ -0,0 +1,68 @@ +# ADR 0236 — Separate the legacy ontology identifier from its compatibility document + +**Decision status:** Accepted +**Date:** 2026-08-26 +**Amends:** [ADR 0207](0207-repository-case-ontology-namespace-canonical.md), decisions 2, 3, and 6 + +## Context + +ADR 0207 correctly made the repository-case namespace canonical and rejected a +second lowercase hosting surface. It nevertheless said that both namespace +documents return `200 OK`. Runtime verification contradicts that statement: +the three tested lowercase paths return `404`, while the canonical ontology and +its published `namespace-compatibility.ttl` artifact return `200`. + +RFC 3986 separates identification from interaction: a URI can identify a +resource without guaranteeing network retrieval. RFC 9110 defines `404` as the +absence of a current representation at the target resource. A compatibility +mapping published at a different URL therefore does not make the legacy +namespace itself dereferenceable. + +## Decision + +1. The repository-case namespace remains the only canonical, dereferenceable + LineageWeave ontology namespace. +2. Lowercase IRIs remain deprecated compatibility identifiers. They are not + described as dereferenceable while their actual paths return `404`. +3. The term-kind-safe compatibility graph remains publicly retrievable at + `https://contextualwisdomlab.github.io/LineageWeave/ontology/namespace-compatibility.ttl`. + This artifact maps legacy identifiers; it is not a representation served + from the legacy namespace. +4. Producers continue minting only repository-case IRIs. Historical RDF and + provenance remain immutable, and the existing dry-run-first migration + rewrites eligible stored lowercase values without touching provenance. +5. Documentation and release evidence report the canonical and compatibility + artifact URLs separately and verify their actual HTTP status. A future + owned lowercase route requires a new ADR and runtime proof before any + dereferenceability claim. + +## Consequences + +- Consumers can resolve legacy terms through the published mapping document + without treating a `404` namespace as a live endpoint. +- The product no longer promises a hosting surface it does not own. +- ADR 0207 remains authoritative for canonical identity, mappings, migration, + OWL/SKOS term kinds, and SHACL; only its lowercase dereferenceability claim + is amended. + +## Release evidence + +The 2026-08-26 publication audit observed these responses after redirects: + +- `404` — `https://contextualwisdomlab.github.io/lineageweave/ontology` +- `404` — `https://contextualwisdomlab.github.io/lineageweave/ontology/ontology.ttl` +- `404` — `https://contextualwisdomlab.github.io/lineageweave/ontology/namespace-compatibility.ttl` +- `200` — `https://contextualwisdomlab.github.io/LineageWeave/ontology/ontology.ttl` +- `200` — `https://contextualwisdomlab.github.io/LineageWeave/ontology/namespace-compatibility.ttl` + +Recheck these URLs on every publication release; this dated observation is +release evidence, not a permanent availability guarantee. + +## References + +Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform resource +identifier (URI): Generic syntax* (RFC 3986). RFC Editor. +https://doi.org/10.17487/RFC3986 + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC +9110). RFC Editor. https://doi.org/10.17487/RFC9110 diff --git a/docs/adr/0237-accelerator-runtime-service-boundary.md b/docs/adr/0237-accelerator-runtime-service-boundary.md new file mode 100644 index 000000000..bda90751b --- /dev/null +++ b/docs/adr/0237-accelerator-runtime-service-boundary.md @@ -0,0 +1,110 @@ +# ADR 0237 — Accelerator runtimes stay behind owning service contracts + +**Decision status:** Accepted +**Date:** 2026-08-26 +**Related:** ADR 0076, ADR 0083, ADR 0208 + +## Context + +LineageWeave runs with Docker Compose on Linux, macOS, and Windows hosts, but +does not own model inference or scientific computation. Adding MLX, CUDA, or +OpenCL devices to its backend would duplicate upstream capability selection, +couple the evidence API to host drivers, and make CPU-only installations less +portable. + +The accelerator mechanisms are platform-specific. MLX targets Apple silicon's +unified CPU/GPU memory. Docker Compose can reserve an NVIDIA GPU only when the +host and daemon expose it, while the NVIDIA Container Toolkit injects host +devices and driver mounts into a Linux container. OpenCL discovers vendor +implementations through an installable-client-driver loader, so an image alone +cannot prove that a compatible device and vendor driver are present. + +## Decision + +1. LineageWeave owns no MLX, CUDA, OpenCL, GPU, or scientific CPU runtime. + Its backend and frontend remain portable consumers of authenticated, + versioned service contracts. +2. LLM, VISION, and embedding acceleration belongs to + contextual-orchestrator or a provider-neutral inference service registered + behind it. On Apple silicon, an MLX process runs natively as such a service; + LineageWeave does not pass Metal devices into its Linux VM or encode an MLX + URL, model, port, or chat template. +3. TEPP and fast-mlsirm own their construct-specific scientific and + psychometric Rust cores. Their compute services may publish separate CPU and + accelerator deployment profiles: deterministic multithreaded CPU is the + portable required path; CUDA uses an explicit Compose GPU reservation plus + a compatible host driver/toolkit; OpenCL uses an explicitly mounted device + and matching vendor ICD. RankWeave remains the dependency-free Python owner + of retrieval fusion and evaluation behind its published contract; this ADR + neither changes its implementation language nor transfers psychometric + ownership to it. None of these profiles are added to LineageWeave Compose. + Podman owners request GPUs through supported GPU/CDI device contracts. A + Colima `krunkit` model runner is likewise an inference-owner deployment on + supported Apple-silicon hosts, not a reason to expose a device or model + selector in LineageWeave. +4. LineageWeave connectors accept only the owner's provider-neutral envelope. + Persisted evidence records the owner, contract/model version, input/output + digest, execution-device class reported by the owner, convergence or + completion state, and uncertainty where the construct requires it. A device + label is provenance, not a quality score. +5. Missing devices, drivers, ICDs, or owner services fail at the owning service + boundary. LineageWeave shows unavailable/failed status and the next valid + action; it never retries on a guessed backend, computes a Python substitute, + or claims GPU execution from configuration alone. + +```mermaid +flowchart LR + LW[LineageWeave API and UI] -->|provider-neutral contract| CO[contextual-orchestrator] + LW -->|measurement contract| M[TEPP / fast-mlsirm service] + LW -->|retrieval-fusion contract| R[RankWeave] + CO --> N[Native MLX service on Apple silicon] + CO --> P[Remote or container inference provider] + M --> C[Deterministic multithreaded CPU] + M --> G[Owner CUDA or OpenCL profile] +``` + +## Considered alternatives + +- **Add accelerator profiles to LineageWeave Compose.** Rejected because this + repository does not own the computation and cannot validate host drivers for + another service's algorithm. +- **Run every accelerator natively.** Rejected because CUDA containers are a + supported owner deployment when their host prerequisites are explicit. +- **Use CPU fallback inside LineageWeave.** Rejected because it would reproduce + the formula on the wrong side of the contract. + +## Consequences and acceptance + +- LineageWeave Compose remains CPU-portable and contains no device reservation. +- TEPP and fast-mlsirm bear deployment and recovery-test work for every + advertised scientific-compute profile. RankWeave retains its own retrieval + fusion/evaluation conformance contract. +- A scientific-compute integration is accepted only when its owning repository + proves the same versioned synthetic input on deterministic CPU and each + advertised accelerator, reports bounded numerical tolerance and device + provenance, and LineageWeave proves malformed, mismatched, and unavailable + envelopes fail closed without exposing implementation details in customer + copy. +- Native MLX availability is verified at contextual-orchestrator's provider + boundary; CUDA/OpenCL availability is verified in the compute owner's health + and conformance evidence. A Compose declaration by itself is insufficient. + +## References (APA 7th) + +Docker, Inc. (2026). *Run Docker Compose services with GPU access*. +https://docs.docker.com/compose/how-tos/gpu-support/ + +Khronos Group. (2026). *OpenCL registry*. +https://registry.khronos.org/OpenCL/ + +MLX Contributors. (2026). *Unified memory*. MLX documentation. +https://ml-explore.github.io/mlx/build/html/usage/unified_memory.html + +NVIDIA Corporation. (2026). *NVIDIA Container Toolkit architecture overview*. +https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/arch-overview.html + +Podman Contributors. (2026). *podman-pod-create*. +https://docs.podman.io/en/latest/markdown/podman-pod-create.1.html + +Colima Contributors. (2026). *Colima: Container runtimes on macOS (and Linux) with +minimal setup*. GitHub. https://github.com/abiosoft/colima diff --git a/docs/adr/README.md b/docs/adr/README.md index 83e56345c..ac24b84a2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -14,9 +14,10 @@ decision from them. | [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md), [0169](0169-ask-batched-lineage-graph.md), [0172](0172-event-lineage-channel-evidence.md), [0202](0202-ask-event-time-filter.md), [0223](0223-explicit-semantic-content-unit-kinds.md) | | [`PROV_O_IMPLEMENTATION.md`](../PROV_O_IMPLEMENTATION.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.md) | -| [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) | +| [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0236](0236-legacy-ontology-namespace-publication.md), [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | | [`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) | +| Per-post Ask conversation history | [0235](0235-post-ask-conversation-history.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) | | [`GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md`](../doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md) | [0216](0216-global-ask-knowledge-cutoff.md) | @@ -27,6 +28,7 @@ decision from them. | Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.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) | +| Accelerator deployment boundary | [0237](0237-accelerator-runtime-service-boundary.md), [0076](0076-paper-grounded-model-policy.md), [0208](0208-externalize-local-mathematical-compute.md) | [0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/). diff --git a/docs/doctoring/MCP_GLOBAL_ASK_REFERENCES.md b/docs/doctoring/MCP_GLOBAL_ASK_REFERENCES.md index 9d51e8408..f3f1084b6 100644 --- a/docs/doctoring/MCP_GLOBAL_ASK_REFERENCES.md +++ b/docs/doctoring/MCP_GLOBAL_ASK_REFERENCES.md @@ -28,4 +28,3 @@ https://doi.org/10.17487/RFC9700 Model Context Protocol. (2025). *Transports: Streamable HTTP* (Specification 2025-06-18). https://modelcontextprotocol.io/specification/2025-06-18/basic/transports - diff --git a/docs/doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md b/docs/doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md index c0ee3eae6..4a9ae2561 100644 --- a/docs/doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md +++ b/docs/doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md @@ -89,9 +89,10 @@ repository-case namespace `https://contextualwisdomlab.github.io/LineageWeave/ontology#` is now canonical ([ADR 0207](../adr/0207-repository-case-ontology-namespace-canonical.md), superseding [ADR 0157](../adr/0157-public-ontology-namespace-identity.md), -resolving issue #372). The lowercase form is a deprecated compatibility -vocabulary; both documents stay dereferenceable through the published -site with validated term-kind mappings, and stored values migrate via +resolving issue #372 and amended by [ADR 0236](../adr/0236-legacy-ontology-namespace-publication.md)). +The lowercase form is a deprecated compatibility identifier whose own path is +not served; the repository-case site publishes a dereferenceable compatibility +document with validated term-kind mappings, and stored values migrate via `scripts/migrate_legacy_namespace.py` with its dry-run/refusal discipline (direction reversed: lowercase rows rewrite to repository-case). The inventory above remains the historical evidence diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md index 657bdd3bf..1ebdc5616 100644 --- a/docs/operability/http-concurrency-evidence.md +++ b/docs/operability/http-concurrency-evidence.md @@ -168,6 +168,16 @@ profile next; it does not by itself assign causality or establish an SLO. ## Older-image diagnostic observation +On 2026-08-26, the application-ready local synthetic stack completed a +5-VU, 15-second k6 run with five iterations and 0 failed requests. Ask enqueue +was 967.49 ms; Ask polling averaged 11.94 seconds; ordinary authenticated +posts/lineage reads averaged 34.53 seconds (p95 43.26 seconds). The run used +the stack's deployed image rather than this candidate head, so it proves only +that the existing HTTP boundary remains responsive without request failures +while also exposing unacceptable reader latency for investigation. It is not +an ADR 0235 capacity result or an SLO. Rebuild the exact candidate image and +repeat with resource and query-plan telemetry before attributing the delay. + On 2026-08-26, the same non-exact local Compose boundary completed an authenticated 4-VU, 30-second run over 43,189 aggregate synthetic `source_post` rows: 87 full iterations, 263 HTTP requests, and 261/261 endpoint diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 0d456f2ae..026b9c496 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -40,8 +40,9 @@ edge exposes the same authorized endpoints and evidence through API and UI. ### PRD-FR-2 — Ontology and semantic layer -- Publish one canonical repository-case OWL/RDFS/SKOS vocabulary with a - dereferenceable lowercase compatibility vocabulary. +- Publish one dereferenceable canonical repository-case OWL/RDFS/SKOS + vocabulary plus a public term-kind-safe mapping document for deprecated + lowercase compatibility identifiers. - Project typed Post, Person, CorporateEntity, Team, Project, and governed relationship evidence from PostgreSQL without creating a second mutable source of truth. @@ -146,6 +147,8 @@ knowledge cutoff, status, citations, and limitations; cross-account reads are - Consume TEPP accepted/completed wire contracts and fast-mlsirm outputs; do not reimplement their arithmetic. +- Let an authorized user request and start a new current-snapshot measurement + after a Failed run without mutating the terminal run history. - Use only provenance-bearing estimated weights anchored by independent lineage evidence. - Keep calibrated measurement, reconstruction relevance, and external truth @@ -242,7 +245,7 @@ current boundary until that repository adopts one. | `ContextualWisdomLab/keyverse` | `docs/PRD.md` | Production OIDC/JWKS/identity control plane; local demo Keycloak is not Keyverse | | `ContextualWisdomLab/RankWeave` | No standalone PRD; `README.md`, `ARCHITECTURE.md` | Store-agnostic ranking/fusion dependency; caller owns channels and authorization | | `ContextualWisdomLab/ThreadWeave` | `docs/PRD.md` | Deterministic reference-thread assembly dependency; LineageWeave owns records and persistence | -| `ContextualWisdomLab/DiskSage` | No standalone PRD; `docs/superpowers/specs/2026-07-10-disksage-design.md` | Prospective storage-policy boundary; no current runtime integration | +| `ContextualWisdomLab/disksage` | No standalone PRD; `docs/superpowers/specs/2026-07-10-disksage-design.md` | Prospective storage-policy boundary; no current runtime integration | | `ContextualWisdomLab/wardnet` | No standalone PRD; `README.md`, `docs/architecture.md` | Prospective gateway/network-policy boundary; no current runtime integration | | `ContextualWisdomLab/naruon` | Scoped `docs/topic-intelligence/PRD.md` only | Owns observed calendar/email projections; LineageWeave owns commitments and combined display | | `ContextualWisdomLab/LineageWeave` | This PRD, with ADRs normative | Evidence BI/orchestration, lineage, semantic projection, API, and UI owner | diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7704fa748..dc281fb0d 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,7 +1,7 @@ # Product & Technical Gap Baseline -> Dashboard delivery snapshot: 2026-08-26 07:15 KST. Protected `main` was -> `494b54e2245040bcf02b45376f221c37cd437e76`. This local branch is not +> Dashboard delivery snapshot: 2026-08-26 18:51 KST. Protected `main` is +> `ff7431bd1851c03e737808d22c6a2d43968582f9`. This local branch is not > protected-main release evidence. ## Operations Dashboard PRD/TRD traceability @@ -14,8 +14,9 @@ | 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 | +| Per-post Ask continuity | ADR 0235; account/post-scoped normalized sessions and current-authorization citation projection | PR #677 merged into the non-default baseline branch as `80c59672`; retarget to `main` and revalidate after protected parent delivery | | 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 | +| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 and TEPP contract PR #237 are on their protected main branches; #237 intentionally publishes the fail-closed wire contract without an estimator, so no emitted, digest-bound terminal 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) | ### Technical contract and flow @@ -60,14 +61,15 @@ only aggregate, non-identifying evidence to this repository. ### Exact open-PR boundary -At this snapshot there were 11 open PRs and 10 open issues. PRs #660 and #659 -merged to protected `main`; PR #666 remains only non-default-branch stack -composition inside #663. Every remaining open head required refreshed hosted -gates and/or independent review after the base changed. These observations are -not merge readiness. Re-fetch exact heads, unresolved threads, checks, -approvals, rulesets, and merge SHA before any lifecycle claim. +At this snapshot there were 35 open PRs and 12 open issues. The compact +exact-head inventory in section 1 is authoritative for this snapshot. Main- +target heads remained blocked on hosted gates and/or independent review, while +stacked heads require protected parent delivery first. These observations are +not merge readiness. Re-fetch exact heads, +unresolved threads, checks, approvals, rulesets, and merge SHA before any +lifecycle claim. -> Audit snapshot: 2026-08-26 07:15 KST (refreshed by the autonomous merge +> Audit snapshot: 2026-08-27 13:41 KST (refreshed by the autonomous merge > loop). This repository records synthetic fixtures and aggregate, > non-identifying runtime evidence only. Open PRs and local checks are not > protected-default-branch release evidence. Identifying post identifiers, @@ -76,31 +78,242 @@ approvals, rulesets, and merge SHA before any lifecycle claim. ## 1. Exact-head and governance evidence -The protected default branch was `494b54e2245040bcf02b45376f221c37cd437e76` -when this baseline was refreshed. The live queue contained 11 open PRs and 10 -open issues. The exact-head inventory below supersedes older per-PR snapshots -elsewhere in this document; those older rows remain useful historical delivery -context only. +The protected default branch is `ff7431bd1851c03e737808d22c6a2d43968582f9` +at this refresh. The live queue contains 35 open PRs (19 targeting `main`, +16 stacked on non-default parents) and 12 open issues. The compact live index +below is the exact-head authority for lifecycle state at this snapshot. The +longer narrative rows that follow remain requirement evidence, but their heads +are historical and must not be used for a merge decision. + +| PR | Exact observed head | Base | Observed lifecycle state | +| ---: | --- | --- | --- | +| #749 | `634b2601` | `feat/onet-rating-occupation-filter` | clean stack; auto-merge off | +| #747 | `e20673a0` | `feat/operations-candidate-priority` | clean stack; auto-merge off | +| #746 | `17e87747` | `feat/onet-rating-source-catalog` | clean stack; auto-merge off | +| #745 | `771cc889` | `feat/onet-occupation-selector` | clean stack; auto-merge off | +| #743 | `85af0fca` | `feat/onet-rating-evidence-ui` | clean stack; auto-merge off | +| #742 | `72fe9b8b` | `feat/operations-candidate-priority` | clean stack; auto-merge off | +| #740 | `36aa851f` | `feat/onet-occupation-ratings-contract` | clean stack; auto-merge off | +| #733 | `822059ca` | `feat/io-psych-construct-extraction` | clean stack; auto-merge off | +| #732 | `8d50f454` | `feat/soc-2018-full-hierarchy` | clean stack; auto-merge off | +| #726 | `eeea3bcc` | `feat/io-psych-construct-catalog` | clean stack; auto-merge off | +| #720 | `dda0531d` | `main` | blocked on required review; auto-merge on | +| #719 | `396b712c` | `feat/io-psych-construct-ontology` | clean stack; auto-merge off | +| #718 | `962022fe` | `feat/fja-worker-function-ontology` | clean stack; auto-merge off | +| #717 | `7a259a42` | `feat/voice-of-x-complete-taxonomy` | clean stack; parent #713 first | +| #716 | `65e1dcdc` | `fix/structured-workflow-exact-pin` | clean stack; auto-merge off | +| #713 | `850494c3` | `main` | blocked on required review; auto-merge on | +| #711 | `27f5ba38` | `feat/dashboard-case-metrics` | clean stack; auto-merge off | +| #709 | `8ef4090c` | `main` | blocked on required review; auto-merge on | +| #704 | `7b9a70ee` | `main` | blocked on required review; auto-merge on | +| #702 | `4325941e` | `main` | repair checks/review pending; auto-merge on | +| #701 | `cc3351a9` | `main` | blocked on required review; auto-merge on | +| #700 | `1bc99eca` | `main` | blocked on required review; auto-merge on | +| #680 | `d7f3e609` | `main` | blocked on required review; auto-merge on | +| #679 | `135dfe7c` | `main` | blocked on required review; auto-merge on | +| #672 | `a3e87a89` | `main` | blocked on required review; auto-merge on | +| #668 | `234f975b` | `main` | blocked on required review; auto-merge on | +| #667 | `e15c507d` | `main` | blocked on required review; auto-merge on | +| #658 | `5f3bc384` | `main` | blocked on required review; auto-merge on | +| #657 | `355a5796` | `main` | blocked on required review; auto-merge on | +| #644 | `f53dd28e` | `main` | blocked on required review; auto-merge on | +| #643 | `bae04cff` | `main` | blocked on required review; auto-merge on | +| #640 | `cede3add` | `main` | review required by ruleset; auto-merge on | +| #639 | `2f4b1bff` | `main` | blocked on required review; auto-merge on | +| #632 | `24262a99` | `main` | blocked on required review; auto-merge on | +| #629 | `b721b0f2` | `main` | blocked on required review; auto-merge on | | PR | Exact observed head | Merge/check state at this snapshot | | ---: | --- | --- | -| #667 | `3bc662d7` | refreshes protected-main and open-queue documentation evidence; base conflict remains to be repaired | -| #663 | `6fd2f701` | combined Project ontology candidate plus #666's non-default-branch removal of sampled region-coverage arithmetic; base conflict remains to be repaired | -| #658 | `f007a5ed` | evidence-honest Global Ask cutoff; hosted checks and independent review required | -| #657 | `2d9b43b7` | TEPP asynchronous lifecycle persistence while unpublished producer work stays unavailable; hosted checks and independent review required | -| #644 | `ed8d97f3` | native frontend surface code splitting; hosted checks and independent review required | -| #643 | `7fb4d18c` | shared token-backed status notice; hosted checks and independent review required | -| #640 | `2d50fa01` | dashboard case metrics and project journeys; base conflict remains to be repaired | -| #639 | `48065ad1` | restores Running action and Compose contracts; hosted checks and independent review required | -| #632 | `29aee18d` | graph-fact provenance, public verification, MCP admission, and k6 evidence; hosted checks and independent review required | -| #631 | `665046dc` (observed parent) | decomposes closed PR #490; this merge refresh advances its head and restarts hosted review evidence | -| #629 | `0138db5f` | provider-work release and bounded landing reads refreshed onto protected `main`; hosted checks and independent review restarted | +| #747 | `659c100c` | stacked on exact #716 and adds linked user, operations, and MCP manuals for the current product contract instead of duplicating behavior across README and ADRs. The audited copy directs tied/missing/unavailable relationships to the catalog steward instead of claiming an unshipped product control, names the shipped failed-content requeue command, and prohibits direct row/wake-up manipulation. Both MCP tools, owner-scoped states, citations, Retry-After, Compose/Make targets, and relative links match code; no provider model, credential, real record, or runtime identifier is embedded. Thirty-four focused checks pass, review threads are resolved, and hosted checks plus independent review remain required | +| #746 | `4983b291` | replaces closed-unmerged #744 and exposes authenticated occupations represented in one imported rating source. It preserves the complete persisted rating and source/scale provenance domain, uses the observed title/code catalog and native selection without ranking or typed fallback, fences authentication/source/occupation/late responses, distinguishes unavailable catalog from an imported empty result, and aligns empty-evidence guidance with the selector's next action. Hosted checks and independent review remain required | +| #745 | `8aa97347` | stacked on exact #746 and adds only case-insensitive published-title or retained-code substring filtering over the native catalog, without ranking or typed SOC fallback. The current normal-merge ancestry preserves #746's request, authentication, source, occupation, loading, unavailable-catalog, and empty-guidance fences; invalid selection clears and requires a visible catalog choice. Review threads are resolved, while hosted checks and independent review remain required | +| #743 | `d23e1deb` | stacked on exact #740 and catalogs only imported occupation-rating artifacts that contain observations, preserving release, publisher, license, URL, digest, and declared row-count provenance while excluding the scale support artifact. The latest head normally reconciles the advanced evidence-view parent while retaining authentication and late-response fencing; hosted checks and independent review remain required | +| #742 | `72782870` | stacked on the latest operations candidate and persists request-scoped normalized product mentions and closed-code relations atomically, re-applies eligibility and ABAC on reads, projects OWL/SHACL assertions, and keeps provider-backed production fail-closed. The latest repair authorizes product-relation target evidence while retaining predicate/product assertion identity; hosted checks and independent review remain required | +| #740 | `e6efa6fe` | stacked on the current occupation-rating read branch and adds an authenticated Dashboard evidence view without changing the governed GNB. It preserves exact values, bounds, sample/error/interval evidence, dates, suppression/not-relevant warnings, and provenance while distinguishing unavailable source from an imported empty profile. The latest repair hides stale occupation pagination as soon as a new request supersedes it, keeps artifact links HTTP(S)-only, and retains the actionable fallback; focused checks pass, while hosted checks and independent review remain required | +| #735 | `3d3463e8` | stacked on the current O*NET linkage branch and imports caller-pinned rating and Scales Reference artifacts under the ADR 0257 schema boundary. It preserves exact decimals, uncertainty, optional category/relevance, release/source partitions, both artifact digests, and exact source month without local weighting or psychometric arithmetic. PRs #734 and #738 merged normally into the parent chain, carrying the partitioned 3NF store and authenticated bounded read projection; hosted checks, independent review, and parent delivery remain required | +| #733 | `17c554a9` | stacked on exact #726 under union-free ADR 0258 and projects authorized occupational assertions into the bounded ontology neighborhood with source eligibility, cutoff, and persisted-truth boundaries. Work evidence uses a double-outline rounded rectangle and a visible localized type label while Team remains single-outline; DB and ontology labels agree, construct focus fails closed instead of exposing a raw UUID, and disabled construct analysis no longer misreports another channel's durable job as construct processing. The latest repair replaces an unrelated global vocabulary-count assertion with a direct proof that every worker-function IRI lacks `lookupCode`; forty-five focused ontology checks pass, review threads are empty, and hosted checks restarted | +| #732 | `8dd5adb2` | stacked on exact #731 under union-free ADR 0256 and publishes 1,417 directed O*NET linkage assertions from eight pinned source tables with reified subject, predicate, object, and provenance and without weights or causal claims. PR #734 merged normally into this branch, carrying the partitioned 3NF rating store without local weighting; hosted checks, independent review, and parent delivery remain required | +| #731 | `b3b9b360` | stacked on exact #724 and publishes all 3,006 O*NET 31.0 Content Model reference elements as deterministic SKOS with exact source identifiers, hierarchy, provenance, license, and artifact digest; ADR 0255 explicitly supersedes only ADR 0248's prohibition on complete pinned O*NET vocabulary publication and preserves the evidence/no-equivalence boundaries. The latest head is an ancestry-only restack carrying #724's publication-label repair; canonical graph publication, review threads, and all observed exact-head hosted checks pass, while independent review and parent delivery remain required | +| #728 | `e52a8272` | now stacks on exact #640 instead of extending protected-main NumPy arithmetic. It pins fast-mlsirm PR #1457 exact `5c7dc9ea` and consumes the owner-produced Rust/PyO3 explained-share field while retaining LineageWeave validation, persistence projection, API, and presentation only. The audit also moved the schema change to union-free migration 0236 and made the production-equivalent bootstrap execute it. Upstream Rust tests pass; LineageWeave backend/schema/frontend focused checks pass; both review queues have zero unresolved threads. The upstream PR remains draft with hosted gates in progress and the LineageWeave parent remains non-default, so neither is protected delivery | +| #726 | `d6a12fbb` | stacked on exact #723 and admits occupational constructs only through contextual-orchestrator conduct, offered permanent IRIs, and verbatim semantic-unit evidence. Attempt-fenced lease refresh prevents concurrent long extraction, a later-enabled analysis service wakes succeeded current-digest rows that lack construct evidence, and disabled construct analysis no longer exposes another channel's queued/running job as construct processing. Twenty-three focused checks pass with one live integration skip, review threads are empty, and all observed exact-head hosted checks pass; independent review and parent delivery remain required | +| #724 | `1d2f8052` | targets the occupational-taxonomy stack and publishes the complete official 2018 SOC hierarchy from pinned, digest-verified source artifacts. Source-column hierarchy becomes `skos:broader` without title/code inference, and no occupation-to-person trait, employer job-family mapping, crosswalk, score, or weight is invented. The served Turtle is a deterministic generated equivalent of the governed source graph, not mislabeled as the authoritative source, and RDF isomorphism plus the source manifest prove that boundary. The focused publication regression, review threads, and all observed exact-head hosted checks pass; independent review and parent delivery remain required | +| #723 | `316fc190` | stacked on exact #721 and synchronizes the official O*NET 31.0 construct catalog under ADR 0250 without importing ratings, scores, crosswalks, affect labels, or person traits. The official 485,151-byte, 3,006-row source artifact is pinned by canonical SHA-256 before a database transaction; the governed scope admits 2,529 constructs, rejects changed or whitespace-mutated labels, and keeps absent descriptions as `NULL`. Synthetic fixtures inject their own declared digest rather than weakening the production pin. Focused checks and the official digest/count replay pass; all threads are resolved and hosted checks remain in progress pending parent delivery | +| #721 | `9214c50f` | stacked occupational-construct persistence candidate. The current head synchronizes its parent and removes the duplicate construct-detail field; lint and production build pass, all threads are resolved, and auto-merge remains disabled pending parent delivery | +| #720 | `dda0531d` | cancels stale test runs after PR closure. A normal failed-job rerun reached terminal attempt 2 and again failed closed only after NVIDIA primary `429`, retired NVIDIA fallback `410`, and direct OpenAI `insufficient_quota`; this is current provider-infrastructure evidence, not a code finding or a passing scan. Normal auto-merge is enabled, but authoritative Strix evidence and independent review remain required before protected delivery | +| #719 | `6ee2278a` | stacked on current #718, exposes the governed `dcterms:rights` read model, and preserves parent ancestry through a normal merge. Fifty-four focused checks pass, all threads are resolved, and auto-merge remains disabled pending parent delivery | +| #718 | `2723fea3` | stacked on #709 and adds evidence-bound occupational constructs with governed provenance wording. Eighteen focused checks pass, all threads are resolved, and auto-merge remains disabled pending parent delivery | +| #717 | `3e332fdb` | stacked on #713 and persists governed evidence-bearing Voice-of-X combinations. Customer-visible assignment events are localized, unchanged Voice codes retain their original effective time, and rejected/superseded assignments render their persisted truth state rather than appearing accepted. The latest documentation distinguishes cutoff-scoped Voice evidence from history while retaining paged JSON-LD evidence; hosted checks and independent review remain required | +| #716 | `65e1dcdc` | stacked on exact #711 and prioritizes the evidence-bound operations backfill with isolated responsive dashboard acceptance. PRs #739 and #714 merged normally into this parent, carrying READ COMMITTED two-tier de-duplication plus bounded public-source research with fail-closed visibility and provenance. The representative anonymized-runtime `EXPLAIN (ANALYZE, BUFFERS)` evidence required by ADR 0206 remains absent, so production performance acceptance and protected parent delivery remain incomplete | +| #713 | `850494c3` | proposes the ADR 0246 twelve-code atomic stakeholder Voice-of-X post taxonomy while preserving ADR 0251's separate extensible combination model and the independently governed organization-relationship vocabulary. The audit moved all twelve labels into the parent so Korean, Chinese, Japanese, and Vietnamese filters do not fall back to English immediately after this PR lands. Sixty-eight i18n and 24 ontology checks plus production build pass; a 1280x720 Korean filter render was inspected, all threads are resolved, auto-merge is enabled, and fresh hosted checks plus independent review remain required | +| #711 | `05e5f520` | stacked on the current #640 head rather than `main`. A proposed pin advance was reverted after exact ancestry showed contextual-orchestrator #882 merged only into a non-default upstream stack and is not protected-main delivery; the immutable runtime pin therefore remains unchanged. It now includes the normally merged worker-memory evidence stack. Its canonical upstream runtime-evidence thread stays unresolved and auto-merge remains disabled pending protected upstream delivery and exact-image proof | +| #710 | `020ff089` | records the worker-function delivery gap against #709 without duplicating #702's corpus audit; the latest documentation refresh records the exact protected queue evidence and keeps all referenced heads snapshot-relative. Hosted checks and independent review remain required | +| #709 | `8ef4090c` | publishes the DOT/FJA worker-function taxonomy and fail-closed read model without converting definitional ranks into weights or asserting an unsupported term-level crosswalk. Exact-head hosted checks and independent review remain required | +| #704 | `7b9a70ee` | publishes the bounded external-lineage contract with fail-closed structured adjudication and exact same-group inference eligibility. The hosted full-suite repair removes fabricated random LLM-channel measurement from tests, keeps LLM fusion unavailable without an estimated LLM-inclusive weight, tests the provider boundary directly, and restores public-docstring coverage. Thirty focused checks and Ruff pass locally; fresh hosted checks and independent review remain required | +| #702 | `8b51c738` | makes missing source-body evidence explicit, carries governed source classifications through semantic hints and RDF/SHACL, and requires probability-sample manifest v3 plus an exact replayed fast-mlsirm Rust design artifact. For the 43,814-record eligible frame, the predeclared SRSWOR design yields `n=381` at 95% confidence, error bound 0.05, and `p=0.5`; the earlier 500-record diagnostic remains explicitly non-probability-selected and cannot support corpus prevalence or confidence intervals. The current head drains spawned-process results before join, correlates provider trace with attempt provenance, and refreshes stacked estimator delivery evidence; upstream Rust delivery, hosted checks, and independent review remain required | +| #701 | `cc3351a9` | repairs both integration fixtures by invoking `psql -X -v ON_ERROR_STOP=1 -f` exactly like the ADR 0166 production runner, so concurrent indexes remain outside a transaction without a fixture-owned SQL parser; no migration is skipped or suppressed. Forty schema/replay/backend checks pass; protected hosted checks and independent review remain required | +| #700 | `1bc99eca` | adds ADR 0238's bounded versioned caller-parsed conversation-turn envelope, fail-closed whole-result preflight, ordered semantic-unit persistence, and opaque caller evidence references without body-pattern speaker inference. Caller units cannot inherit opaque-body metadata, trigger unused vision work, or admit text the database cannot persist. The current head moves its schema change and paired rollback to union-free migration 0233, applies that migration in the real-PostgreSQL fixture, and binds each evidence action to the inclusion channel that admitted it; protected hosted checks and independent review remain required | +| #680 | `b05e3100` | replaces rendered implementation-boundary language with customer actions, including topic-specific measurement access and exact empty-state actions. The latest repair replaces search-configuration text with saved-evidence/retry guidance in five locales, suppresses raw failure codes, and removes the persistence-delivery outbox from the customer screen while retaining the API record. All 166 App/i18n checks and lint pass; fixed-project Compose renders at 1440x1100 and 390x844 show the status history and next actions without raw failure/outbox terms or horizontal overflow. Fresh hosted checks and independent review remain required | +| #679 | `135dfe7c` | ADR 0229 public-claim envelope, async verification, locale-aware next actions, opt-in search setup, and rollback protection for truth-owned fields. The current head always renders the aggregate verification action alongside per-claim actions, removes internal graph-review copy, and maps unknown future claim/status codes to localized `Recorded claim` / `Status unavailable` instead of exposing wire values. Sixty-nine focused UI/i18n checks pass, all threads are resolved and normal auto-merge is enabled, while exact-head hosted checks and independent review remain required | +| #672 | `a3e87a89` | persisted semantic-evidence nomination for Global Ask uses unique ADR 0233/0234 and migration 0225/0226 identities; production nominee wiring includes organization/team evidence and typed public claims, removes duplicate embedding and token-overlap inference, and separates visible from all KG evidence so hidden identifiers cannot render. The current head repairs the server-diagnostics fixture to follow the configured Global Ask embedding contract after the prior exact head failed; fresh hosted checks and independent review remain required | +| #668 | `234f975b` | evidence-bound project history orders by recorded event time, retains explicit source identity alongside NFKC-deduplicated semantic keys, suppresses false direct handovers, and carries one non-conflicting evidence snapshot. Project-match provenance and unknown future event codes now map to localized Source record / Supporting record / Recorded evidence labels instead of exposing relational field names. Seventy-four focused project-history/UI/i18n checks pass; hosted checks and independent review remain required | +| #667 | `ea17b17d` | this refresh branch prevents stale conversation pagination, recovery, delayed answers, and typed drafts from contaminating a replacement post or conversation; keeps saved turns out of the demo-seed presentation; applies the shared source-eligibility boundary; and replaces provider, transport, environment-variable, timeout, calibration-script, and search-configuration copy with task-specific next actions. Saved conversations page on immutable creation time plus conversation id so a concurrent new turn cannot move a row above the reader's cursor; the observed head also reconciles the live semantic delivery stack. Focused checks pass, all review threads are resolved, normal auto-merge is enabled, and hosted checks plus independent review remain required | +| #658 | `5f3bc384` | sends the validated UTC cutoff directly, removes duplicate/unreachable cutoff state and SQL parameter conflicts, and keeps database-clock cutoff validation and evidence authorization intact. The latest repair replaces transport-oriented cutoff wording and raw RFC3339 output with a five-locale, device-time action and local display formatting; the Storybook interaction follows the same contract. Sixty-seven focused frontend/i18n checks and lint pass, review threads are empty, normal auto-merge is enabled, and exact-head hosted checks plus independent review remain required | +| #657 | `355a5796` | persists strict TEPP accepted receipts without treating acceptance as measurement, resumes a stored remote run through the pluggable status port, validates and persists the terminal DTO before Succeeded, and keeps all arithmetic in TEPP. The latest UI repair removes TEPP, provider, remote-run identifier, and reconciliation internals from the customer surface; the accepted state now asks the user to refresh for result readiness. Sixty-nine backend/schema checks, five focused frontend checks, lint, and Storybook build pass; the 1440×720 accepted-state render was inspected, all review threads are resolved, auto-merge is enabled, and hosted checks plus independent review remain required | +| #644 | `f53dd28e` | current-main reconciliation preserves all existing workspace surfaces and adds Public Claim Verification as the ninth lazy boundary; a subsequent normal merge reconciles concurrent ADR/baseline evidence without deleting either implementation path. Frontend 42 files/391 tests, lint, production build, and Storybook build pass; refreshed desktop loading and mobile error screenshots confirm the actionable alert/Refresh states. The observed 509.58 kB app chunk still triggers Vite's warning and remains measured performance debt; hosted checks and independent review remain required | +| #643 | `8767de1b` | accessible status notices; hosted checks and independent review remain required | +| #640 | `ebfe60af` | dashboard ranking and topic-influence stack preserves Ask service imports, authorization, ontology parity, transactional schema fixtures, the durable worker, bounded structured validation, and atomic voice-backfill completion. PRs #727, #730, and #725 merged normally into this non-default parent: public failures no longer expose provider, environment-variable, timeout, or evidence-assembly boundaries and instead give a retry/administrator action; empty verification gives a specific-claim/time-range retry; worker memory evidence remains aggregate and non-identifying. Focused backend/i18n/rendered tests, lint/build/Storybook, and desktop/mobile inspection pass. Exact-head hosted checks and independent review remain required before protected delivery | +| #639 | `2f4b1bff` | running-action/config repair now also makes the documented `make seed` contract install its declared script/runtime extras and documents the canonical Keyverse frontend variables with a container-contract regression. The exact-head repair updates the Makefile contract test to require those owned extras; 13 focused Makefile/config tests pass locally. Fresh hosted checks are running, auto-merge remains enabled, and independent review is required | +| #632 | `24262a99` | graph-fact provenance repair also carries the current bounded MCP request contract, token-backed Ask layout, ontology-label wrapping, and normalized-table evidence-search index repair. Both live migration fixtures execute the production-equivalent `psql -X -v ON_ERROR_STOP=1 -f` path; 38 schema/replay/contract checks pass locally. A normal Strix rerun request was accepted with HTTP 201 and the replacement exact-head job is queued; acceptance is not a passing scan. Full tests remain in progress and coverage-source-tree is queued, so exact-head terminal revalidation and independent approval remain required | +| #629 | `b721b0f2` | provider-work release and bounded reads now preserve relationship type, capture update status, remove a shadowed legacy verifier, persist each completed result before a later provider failure, fence deleted evidence, and execute both Global Ask migrations twice in the real PostgreSQL fixture. Focused migration/schema tests pass; hosted checks and independent review remain required | + +PRs #687, #688, and #689 merged into the non-default #640 stack as merge +commits `9d4210c04fb0aeec17e885bed097f400c7018269`, +`b597b0182084a89222d1a04d05878adebfa9ec2b`, and +`fbf7afb5a3037254266e5cf1bb024e435b126bb1`. PRs #690 and #691 merged into the +non-default #667 stack as `55b5a48ff997fddbe8fcc47a268d0b02aa835c6a` +and `60b4c6004739a05e63edae8ff160758f8e665919`. +These are stack-integration evidence only; they are not protected-`main` +delivery. Their acceptance now travels with the two parent heads above. + +PR #712 merged into the non-default #702 stack as `78a144107`; it freezes +LineageWeave-local scoring and entity-resolution arithmetic behind ADR 0245 +without inventing a replacement. The whitespace repair for that carried ADR +is present on the current #702 head. This is stack-integration evidence only, +not protected-`main` delivery. + +PR #722 merged normally into non-default parent #640 as `c142c4ea`; it restores +the dedicated Ask worker's semantic-query and opt-in public-verification +factories and the production-equivalent concurrent-migration fixture path. +This is stack-integration evidence only and cannot substitute for #640's +protected-`main` delivery. + +PRs #692, #697, and #694 merged into the non-default #693 composition as +`cdd499b161053a9c5439181e8f39e1baa80e68c0` and +`dc3ebb6911187637cf85fcbcc036cf39e571aaec`. PR #699 merged into the +non-default #694 composition as `d47aae9f77a3f71c7f91929cb258f6e73d1f6d40`; +#694 then merged into the non-default #693 composition as +`0feb7f72cc34fbd23e8f1bd6355c9a5ee49ded89`. Their product-catalog, +voice taxonomy, Python-arithmetic deletion, and Rust-ownership audit evidence +traveled with #693 and now travels with open parent #640 after #693 merged +there as `7adb7a4a9712f2f56a7291d437225a1227debbf8`; none is protected-main delivery. + +PR #663 merged to protected `main` as merge commit +`faff7a32cf9b3c81fefa2814b6f30a0a3ba4e58f`; its project-ontology traversal, +cutoff-snapshot authorization, bounded MCP admission, and caller-parsed +semantic-unit seam are therefore protected delivery. This does not by itself +ship a Naruon conversation-turn producer contract. + +PR #686 was closed without merge at `fbca05d9`; its customer-copy work is not +protected-main delivery and any still-required behavior must travel through an +open current-main candidate rather than relying on that closed head. + +The exact-head scan at 01:40 KST found 12 of 28 heads with a terminal failure, +nine with a queued or in-progress context, and nine with unresolved review +threads. The 01:56 follow-up contained 29 open heads after #723 opened; 22 +targeted `main` with normal auto-merge and seven stacked children correctly +remained off. No current head had a qualifying independent exact-head approval, +so no candidate is protected delivery. +Queued checks, `MERGEABLE`, auto-merge, and bot success statuses are not merge +evidence. + +### Ecosystem owner-boundary evidence + +- `ContextualWisdomLab/contextual-orchestrator` PR #857 exact head + `dc5ed7a1` owns provider-backed embedding discovery, selection, execution, + durable batching, session propagation, and Rust token/vector arithmetic. + Its required hosted checks were queued at this snapshot. LineageWeave may + request embeddings and preserve an orchestrator-returned session model pin; + it must not configure `LLM_GATEWAY_EMBEDDING_MODEL`, infer a provider from a + model name, or present #857 as delivered before its protected merge. +- `ContextualWisdomLab/RankWeave` PR #41 exact head `e95ed46f` contains the + proposed Rust calculation core and the semantic-unit ranking feature merged + from stacked PR #48 (`f3cd7ed7`). PR #48 is therefore stack-integration + evidence only; #41's new exact head has queued hosted checks and no + protected-main merge evidence. LineageWeave may consume the owner contract + only after that composed head passes its protected gate; it may not retain + Python vector arithmetic. +- `ContextualWisdomLab/fast-mlsirm` PR #1445 exact head `e2e86a7d` + supplies the Rust-owned finite-population proportion design artifact consumed + by #702: source and algorithm identities, population, confidence, margin, + expected proportion, uncorrected and corrected sample sizes, allocation, + ordered strata, exact `(n_h, N_h)` inclusion ratios, and + input/output/artifact hashes. The ratios are bound through Rust, PyO3, + Python, output hashing, and artifact hashing. Five Rust and eleven PyO3 + checks pass with `cargo check`, Ruff, and lock verification. The PR is ready + and auto-merge is enabled, but hosted checks and independent approval remain + pending; it is owner-candidate evidence, not protected release evidence. +- `ContextualWisdomLab/TEPP` PR #237 merged to protected `main` as + `eec86be724e9131ecfe8f152db0f7728af68017f`. It publishes the fail-closed + lineage-criterion anchor wire contract but explicitly does not implement the + estimator; LineageWeave must keep weights unavailable until TEPP emits the + digest-bound terminal artifact. No row above is merge evidence. Immediately before any lifecycle action, re-fetch the head, unresolved threads, formal reviews, rulesets, and same-head check conclusions. In particular, queued checks are infrastructure state and do not transfer evidence from an earlier SHA. +### Cross-PR contract collision audit + +The current heads cannot be merged in arbitrary order. PR #672 and stacked +PR #677 formerly assigned ADR 0228 to per-post conversation history while #672 +used it for public-claim verification. The baseline branch now assigns the +conversation contract unique ADR 0235, while #672 uses ADR 0234; the legacy +ontology publication decision now uses ADR 0236, leaving #679's public-claim +ADR 0229 unambiguous. #672's Semantic Ask migrations now use 0225/0226, +separate from #640's 0211/0222 and #679's 0224. The former #640/#663 ADR +0224 and 0225 collisions are resolved on #663 by unique ADR 0230/0231 +identities. A current-head inventory confirms that #702's semantic-coverage +decisions moved to ADR 0240/0241/0242 and #704's external-lineage decision +moved to ADR 0239. #668's evidence-bound project-history projection moved from +the remaining shared 0232 identifier to ADR 0243, and #640's +source-preserving voice semantic taxonomy now owns ADR 0244. That leaves +#709's worker-function taxonomy as the sole ADR 0232 decision. #713's expanded +post-voice taxonomy now owns union-free ADR 0246 and migration 0235; #640 +composes the same post vocabulary without fabricating organization +relationships. The +open-head migration union found one distinct-name collision at 0230: #640's +voice semantic taxonomy and #700's conversation-turn evidence. #700 now owns +union-free migration 0233, preserving #640's accepted 0230 identity; focused +replay against real PostgreSQL confirms the renamed migration is applied. +Merged child #706 originally reused 0233 for post-content validation; it moved +that distinct schema contract to union-free migration 0234 before normal merge +into #640. PR #705 first merged its replay guard into the non-default historical +`fix/ask-auto-source-composer` branch as `db181493`; that merge alone was only +stack-integration evidence. Merged child #707 then forwarded the same guarded +contract into live parent #640 as `cb086313`, so #640 now carries it while main +still does not. +The +`0212_global_ask_knowledge_cutoff.sql` blobs in +PR #658 and #663 are byte-identical, so that overlap is duplicated delivery rather +than a semantic divergence. Release metadata also diverges: the observed +`pyproject.toml` versions are 2.18.0 on #632/#640/#663, 2.19.0 on #643, +and 2.22.0 on #679. Release-version and duplicated-delivery overlaps still +require ordered parent landing, child retargeting, and exact-head +revalidation; ADR identifier collision is no longer the blocker at this +snapshot. + +PR #680 and closed-unmerged #686 independently edited the same ranking, +ontology, and locale surfaces. Before #680 can land, compare it with #686 and +port only still-required customer actions and rendered evidence into the open +current-main candidate; the closed head is not delivery evidence and must not +be used to restore implementation-facing copy. + +The hosted Full test for PR #632 at superseded head `cad4debf` failed only in +the real-PostgreSQL semantic-nomination test with `InvalidPasswordError`; the +other 1,333 tests passed. The run was cancelled after the branch advanced to +`811026cc`, and a fresh exact-head run is queued. This is retained as runner +evidence, not treated as a source regression or a passing gate. + +PR #664 first merged into #660's non-default stack as +`b2e48d5b0db59f5aa434e2a293cd182ee810c019`; PR #660 then passed the protected +gate, followed by PR #659, with current protected merge commit +`494b54e2245040bcf02b45376f221c37cd437e76`. The combined semantic-unit, +backend-contract, and ontology-token implementation is now protected-main +delivery evidence, while downstream authorized-runtime acceptance remains +open. + PR #607 first merged as `61fd631c7bb3c57113fd19763c2c43161eeb2824` into #606's non-default branch. PR #606 subsequently passed the protected gate, so the combined TEPP-consumer and operations-dashboard implementation is now @@ -134,36 +347,21 @@ regression is part of the current candidate and is not delivery evidence yet. Three systemic gates currently dominate the queue: -1. **Strix visibility lookup failure (org control plane).** PR #600 exact head - `7580bdc9` failed before scanning because the required-workflow token could - not resolve this public repository after six API retries. The root repair is - ContextualWisdomLab/.github#1320 at `3b9b2380`: ordinary PR, push, and - schedule runs use trusted event visibility; cross-repository dispatch keeps - authoritative public/private/internal visibility; private and internal - repositories remain on private-capable providers. The exact head also - composes the executable fallback contract and classifies bounded NVIDIA - `ServiceUnavailableError` overload evidence as retryable across configured - distinct models without weakening exhaustion or vulnerability fail-close. - A hosted fallback then completed with zero vulnerabilities but was rejected - because the generic warning gate treated Strix's fallback-model banner and - a Hugging Face unauthenticated-download notice as provider failures. The - current head removes only those two exact scanner notices before the - existing general warning and explicit 429/provider failure checks. The - current head also clears a foreign NVIDIA/OpenRouter endpoint before a - direct-OpenAI fallback while retaining an explicitly configured - direct-OpenAI primary endpoint. The prior full quick-gate harness, overload - path, 12 visibility-contract tests, and the focused cross-provider endpoint - contract passed; exact-head hosted revalidation remains pending. It is blocked on - hosted exact-head gates and independent review, so no repaired - protected-main Strix runtime evidence exists yet. -2. **Strix provider unavailability (org control plane).** The central required - Strix scan on .github#1320 failed when NVIDIA returned `Service temporarily - overloaded`; the gate correctly failed closed but did not try its configured - distinct fallbacks because the service-unavailable classifier excluded the - NVIDIA provider. Exact head `3b9b2380` composes that execution repair and the - two exact non-fatal scanner-notice exclusions while keeping - incomplete exhaustion non-passing. This is still an unmerged control-plane - proposal, not protected-main or downstream runtime evidence. +1. **Strix visibility and fallback repair (org control plane).** + ContextualWisdomLab/.github#1320 merged to protected `main` as + `d2c554dbbc04854db6215970fabb70cef1ceb690`; its former candidate head is no + longer open-PR evidence. Current follow-up #1350 is open at exact head + `045f1789`, with zero unresolved threads and no terminal failed checks, but + its required hosted checks are queued and it has no formal independent + approval. It changes the owned Strix fallback contract and focused + regression only. Until #1350 passes its protected gate and downstream scans + succeed, the seven current LineageWeave Strix failures remain unresolved + control-plane evidence rather than seven proven source defects. +2. **Exact-head hosted evidence.** #1350's local verification and mergeable + state do not prove the central workflow repair. A terminal successful + protected merge and subsequent successful LineageWeave scans are still + required; incomplete provider exhaustion and vulnerability findings remain + non-passing. 3. **Current-head independent approval.** The org merge scheduler requires `reviewDecision == APPROVED` plus complete Strix evidence on the exact head. Bot review evidence regenerates per push, so any repair push resets @@ -174,8 +372,8 @@ Recent protected-default-branch delivery evidence (squash merges onto | PR | Merged (UTC) | Delivered | | ---: | --- | --- | -| #628 | 2026-08-25 12:39 | one-round-trip authorized post filter options without narrowing the complete ABAC-visible set | -| #627 | 2026-08-25 12:35 | preserved valid k6 lifecycle evidence across setup, scenario execution, and teardown | +| #659 | 2026-08-25 22:04 | ontology node readability, semantic-family design tokens, and exact-value evidence tables | +| #660 | 2026-08-25 21:54 | backend runtime contracts and the #664 semantic-unit stack | | #468 | 2026-08-25 08:44 | fast-mlsirm, Keyverse, contextual-orchestrator, and TEPP integration boundaries | | #493 | 2026-08-25 08:44 | evidence-grounded Event Lineage isolation reasons | | #600 | 2026-08-25 08:44 | then-current exact-head product/technical baseline | @@ -218,13 +416,25 @@ public history. Do not reproduce or hint at its value. Historical remediation requires the ADR 0001 incident process and security/privacy-owner coordination; never force-push or delete evidence ad hoc. -The Grok durable hourly loop and the central thin GitHub Actions caller -ContextualWisdomLab/.github#1259 (minute 4, `pr-review-fix-scheduler.yml`) -both target this repository. Do not add a LineageWeave-local duplicate -workflow. ContextualWisdomLab/.github#1258 merged at exact head `897819c4` to -repair the pnpm/coverage-evidence workflow; newly created exact PR heads must -still prove the runtime behavior because merged workflow source alone is not -check evidence. +The repository-owned hourly commercialization loop and the central thin GitHub +Actions caller ContextualWisdomLab/.github#1288 (current head `c78ae017`, minute +4, `pr-review-fix-scheduler.yml`) both target this repository. The central +candidate now reaches model execution through a pinned contextual-orchestrator +sidecar, exposes only its loopback gateway contract to OpenCode, and requires +customer next-action copy without rendered implementation boundaries. PR #1259 is the +closed predecessor and must not be treated as current scheduler evidence. Do +not add a LineageWeave-local duplicate workflow. ContextualWisdomLab/.github#1258 +merged at exact head `897819c4` to repair the pnpm/coverage-evidence workflow; +newly created exact PR heads must still prove the runtime behavior because +merged workflow source alone is not check evidence. +The local `com.contextualwisdomlab.lineageweave-hourly` launchd registration +was live-audited at this snapshot with a 3,600-second interval, 18 recorded +launches, and process exit code 0. Its output log contains earlier completed +queue/repair iterations, but the two latest invocations reported tool-host +negotiation timeouts; a zero Codex process exit therefore does not prove a +successful maintenance iteration. Registration and prior successful work do +not prove the next run or transfer protected-head evidence, so every invocation +still re-reads live PR state and the loop retains explicit fail-closed reporting. Figma design-system boundary (ADR 0002): File ID `1Su3lDRmiZdcUs47t1QwIX`. The sanitized file now contains synthetic Event Lineage desktop (`5:14`) and @@ -279,7 +489,7 @@ evidence across heads. The org merge scheduler merges only when | #258 | `f0b5234d` | Workspace evidence board and source-grounded ontology surface (root stack) | Largest surface; historical CHANGES_REQUESTED is stale relative to current head | | #349 | `bef4a858` | Bounded ontology and provenance explorer (v2.13.0) | Issue #341 | | #355 | `2f3f308c` | Naruon event projection contract | Issues #336/#338 | -| #387 | `5ef0f2e6` | Persist and explain Event Lineage channel evidence | Issue #274 | +| #387 | `0bd93e94` | Persist and explain Event Lineage channel evidence | Protected `main`; historical issue #274 | | #405 | `ec62d9f0` | Persisted image-region locations (v2.12.8) | VISION region provenance | | #484 | `878c4a87` | Allen interval relations on Event Lineage edges (v2.15.0) | Temporal modeling; Allen (1983) | | #490 | `d0cad030` | Wire remaining ADR 0133–0137 surfaces | Consolidated product stack incl. Knowledge Graph token repair | @@ -349,69 +559,75 @@ this file per §3.5 of the prior snapshot). | #79 | Milestone 2: port verified direct-PostgreSQL analysis into the protected architecture | analysis-run registry on `main`; remaining runtime bridge | | #87 | Milestone 2.1 normalized runtime-analysis schema bridge | related analysis-run work | | #269 | Authenticated Global Ask MCP browser-safe and admission-bounded | Ask stack | -| #271 | Evidence-honest knowledge-cutoff scope on Global Ask | #658; still open and not protected-main evidence | -| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | #632 preserves internal provenance; public verification acceptance remains open | -| #277 | TEPP: persist accepted receipts, poll completed results, keep measurement authority distinct | #657 consumer lifecycle; executable producer route remains unavailable | -| #280 | Full project-lifecycle history and handover intervals | #640 adds case/project journeys and #663 adds evidence-backed Project exploration; authoritative lifecycle reconciliation remains #284 | +| #271 | Evidence-honest knowledge-cutoff scope on Global Ask | Ask stack | +| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | Ask stack | +| #277 | TEPP: persist accepted receipts, poll completed results, keep measurement authority distinct | #468, #417 | +| #280 | Full project-lifecycle history and handover intervals | Tracked with issue #284; no active delivery PR confirmed | | #284 | Authoritative lifecycle ingestion and idempotent reconciliation | No active delivery PR confirmed | -| #338 | Evidence-bounded email/project lineage contract for Naruon consumption | Missing on protected `main`; #343 merged only into a non-default stack, while #355 is a distinct calendar-consumer contract and is not delivery evidence for email/project lineage | -| #611 | Decompose closed PR #490 ADR 0133–0137 evidence without transferring stale branch state | #631 supplies the current-main inventory only; focused implementation PRs and tests for every unmet criterion are still required | +| #338 | Evidence-bounded email/project lineage contract for Naruon consumption | No active delivery PR; #355 is merged historical consumer work | +| #611 | Reconcile historical ADR 0133–0137 proposals from closed PR #490 with current product authority | #667 records the current-main evidence and removes ADR 0137's unauthorized duplicate identity scope | + +### 4.1 ADR 0133–0137 current-main decomposition + +Closed PR #490 is recoverable source evidence, not delivery evidence. The +matrix below compares its five decisions with protected +`main@ff7431bd1851c03e737808d22c6a2d43968582f9`; it does not transfer #490's +reviews, checks, or 321-file tree. Current accepted ADRs and the PRD determine +whether a historical proposal is delivered, still pending, superseded, or +outside LineageWeave's product authority. + +| Decision | Protected-main evidence | Classification | Focused acceptance before delivery | +| --- | --- | --- | --- | +| ADR 0133 — source-reference research | Superseded by accepted ADR 0215 and PRD FR-5A; protected-main deliveries #641 and #682 provide the opt-in, bounded SearXNG and contextual-orchestrator verification boundary | Delivered under current authority | Keep internal evidence, public citations, unavailable states, and entity-binding authority separate; do not recreate the closed branch's parallel schema | +| ADR 0134 — token-backed exception messages | #643 is the active shared `StatusNotice` implementation candidate | In progress, not protected delivery | Land #643 only after exact-head checks and independent approval; then audit remaining raw/color-only exception surfaces with Storybook unavailable/retry scenes | +| ADR 0135 — analysis-kind exact next actions | #639 restores the current run-action contract and #667 carries cancelled-run guidance and responsive layout | In progress, not protected delivery | Land each exact-head candidate through protected gates, then test the remaining kind × status interaction matrix without inventing TEPP or report actions | +| ADR 0136 — per-post Ask history | #667 implements accepted ADR 0235's normalized account-and-post-scoped conversation history, authorization rechecks, pagination, and saved/new UI | In progress, not protected delivery | Land #667 with exact-head backend/frontend checks and independent approval; retain citation-revocation and cross-account/post denial evidence | +| ADR 0137 — cross-post customer identity | PRD FR-7 assigns identity to Keyverse; a LineageWeave-owned customer identity judgment/binding store would duplicate that authority | Not an authorized product gap | Keep identity at the Keyverse boundary and remove the closed branch proposal from the implementation queue | + +The remaining delivery order is ADR 0134/#643, ADR 0135/#639 plus #667 guidance, +and ADR 0136/#667 because their focused current-base heads already exist. ADR +0133 is delivered under ADR 0215, while ADR 0137 is outside LineageWeave's +identity authority; neither starts a new implementation PR. None is protected +for wholesale replay from #490. ## 5. Open product and technical gaps | 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 | -| 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 | +| Protected release | Protected `main@ff7431bd` includes #631's ADR-decomposition documentation and #663's project-ontology/caller-parsed semantic-unit seam in addition to the #660/#664 semantic-unit/backend stack and #659 ontology readability/token repair. Thirty-seven PRs remain open at the 04:13 KST snapshot: twenty-two target `main` and fifteen target non-default stack branches. Every candidate still requires exact-head checks and independent approval before protected delivery | Terminal exact-head checks, no unresolved threads, the current ruleset's independent approval, and a protected merge SHA; re-fetch the ruleset before every lifecycle claim | | 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 | +| Cancelled analysis guidance | PRD-FR-5 requires every lifecycle state to identify a valid next action, while ADR 0013 makes Cancelled terminal. Protected `main@494b54e2` rendered Cancelled without a next action. This stacked candidate adds kind-specific guidance for lineage, TEPP, topic lineage, and period reports; 390×844 and 1440×1000 authenticated synthetic-runtime audits are retained in `docs/screenshots/cancelled-analysis-runs-{mobile,desktop}.png`. The audit also found and repaired attached count/action text and the three-column mobile squeeze | Land through the protected gate, then repeat authenticated keyboard and screen-reader acceptance on the exact release head; no cancelled run may imply that it can resume or that a measurement exists | | Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push | | Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc | | Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence | -| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work, and the synthetic Compose boundary has an authenticated k6 E2E harness for Ask enqueue, concurrent reads, and job polling. PR #633's measured landing-query and event-loop work merged into open parent #629 rather than protected `main`; its aggregate observation improved 25-VU throughput but did not establish a latency SLO. The current exact #629 also persists each completed relation verification before propagating a later provider failure | Land #629 through its refreshed protected gate, rebuild that exact-head application image, and repeat `make load-http` with declared environment concurrency/window and retained raw distributions/resource configuration; set no SLO until representative capacity evidence is approved | +| Corpus semantic-coverage inference | #702 accepts a declared sample only when ordered opaque membership is bound to probability strata, frame hashes, a selection digest, and exact inclusion numerator/denominator pairs that equal fast-mlsirm's Rust-attested `(n_h, N_h)` ratios. Any provider, transport, trace, parse, or item failure stays in the denominator and invalidates the complete sample. The existing five-time-stratum × 100-record diagnostic was not probability-selected and therefore supports pipeline diagnosis only, not corpus prevalence. For `N = 43,814`, a valid simple random `n = 80` implies approximately `±10.95%`, while `n = 500` would imply approximately `±4.36%`, at 95% under `p = .5`; those intervals do not attach to the current diagnostic. The current Rust artifact proves design arithmetic and exact inclusion ratios, not the achieved estimator, variance, design effect, or interval. Corpus inference therefore remains unavailable | Land fast-mlsirm #1445 and #702 through their protected gates. Then execute the complete declared probability sample without replacement of failures and produce a separate terminal Rust artifact for the estimand, estimator, variance, design effect, and achieved interval. Commit only the non-identifying aggregate after every selected member succeeds | +| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work, and the synthetic Compose boundary has an authenticated k6 E2E harness for Ask enqueue, concurrent reads, and job polling. On PR #639 exact head `f6c8c93f`, a local 4-VU/30-second synthetic authenticated run completed 4,650 iterations (152.21/s), 13,952 requests (456.71/s), and zero failed requests. Ask enqueue was 74.90 ms; Ask polling p95 was 29.94 ms; combined post/lineage reads were 23.22 ms average and 40.88 ms p95. The one Ask job settled `succeeded`. A mid-run sample observed backend CPU 98.03%, PostgreSQL CPU 107.59%, Valkey 1.90% with zero rejected connections/evictions, and orchestrator 0.47%; PostgreSQL showed no lock wait. This names CPU pressure at the application/database boundary but does not establish a latency bottleneck, capacity limit, or SLO. The first PR #629 image build also reproduced the pnpm 11 ignored-build failure; PR #639's workspace-policy copy fixes that owned Compose contract and now locks the seed extras in regression. PR #629 exact head `48496ff6` still moves Global Ask question embedding before `pool.acquire()` and remains blocked on independent review | Land #639 and #629 through their protected gates, repeat this declared workload on their merge heads with endpoint-separated trends and repeated resource samples, then increase concurrency only until an observed latency/error knee identifies a bottleneck. Retain raw distributions and resource configuration; set no SLO until representative capacity evidence is approved | | Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing | -| Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts | -| Event and project semantics | #663 is the largest current user-visible gap slice: evidence-backed Project nodes, bounded traversal, cutoff/snapshot fencing, exact-value table parity, and localized graph labels. Focus visibility, label-bound, and temporal test-double regressions are repaired. #666's heuristic removal is composed into this parent but is not separately protected-main evidence. #640 separately adds project journeys without claiming authoritative lifecycle status | Combined #663 must pass exact-head checks and independent approval before protected merge. Aggregate authenticated evidence must still prove distinct projects/events and handover intervals without promoting co-occurrence | -| Knowledge Graph readability | #659 recreates the token-backed node-type repair on current `main`, including regression coverage; it is open and therefore not protected-main evidence | Merge #659 normally, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface | +| Semantic source rendering | ADR 0223 and migration 0221 were first delivered via #664/#660 and remain on protected `main@494b54e2`; persisted paragraph, list, table, MathML formula, and caller-parsed conversation-turn kinds remain explicit and image regions remain ordered normalized children | Prove an authorized semantic-only query retrieves each persisted unit kind and gather authenticated browser evidence that nesting, continuation alignment, formula units, and image regions retain source order | +| Source conversation-turn ingestion | PR #700 adds ADR 0238's bounded versioned PostgreSQL-import envelope, whole-result preflight, ordered turn persistence, and opaque caller evidence references without body-pattern speaker inference. The opaque adapter locator stays private, while an eligible live Global Ask citation can return the typed `open_cited_content_unit(post_id, unit_index)` action after source eligibility and caller authorization succeed. That action opens the already-authorized product unit without revealing or resolving the adapter locator. It is a current-main candidate, not protected delivery; Naruon producer consumption, direct source-system resolution, and released-head runtime ABAC evidence remain absent. Ask conversation history and ThreadWeave message threading remain separate graphs | Land #700 through the protected gate, publish the immutable producer fixture, add a Naruon RFC/JMAP-derived consumer, and prove ABAC non-disclosure, exact-unit citation and navigation, and absence of cross-graph inference on released heads. Add a direct source-system resolver only after a separate authorization and audit ADR defines it | +| Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence | +| Product hierarchy and voice exploration | PRs #692 and #694 merged into #693, then #693 merged only into non-default parent #640. That stack carries the evidence-bound product hierarchy and overlapping voice classifications with source eligibility, ABAC, original evidence, and synthetic-row exclusion from real-source summaries. The open parent is not protected delivery | Land #640 through its protected gate, then prove through authenticated aggregate API and rendered acceptance that authorized users can navigate product hierarchy and overlapping voice classes without exposing hidden evidence or implementation ownership | +| Knowledge Graph readability | PR #659 is protected-main delivery in `main@494b54e2`: ontology node types use semantic-family light/dark tokens while shape and text remain non-color channels; exact-value tables retain full labels | Complete authenticated rendered acceptance for light/dark contrast, keyboard graph navigation, full labels, and evidence tables | | Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding | -| Calendar / Naruon | #355 delivered the projection contract; v2.17.0 wires operator consumption without forwarding the end-user token. Naruon producer, provider/consumer fixtures, and protected merge remain open (#336) | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired | +| Calendar / Naruon | #355 and closed issue #336 delivered the consumer projection and v2.17.0 operator wiring without forwarding the end-user token. Cross-repository email/project lineage issue #338 remains open; producer/consumer runtime fixtures are not protected release evidence | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired | | SKOS organization aliases | Catalog binding and chip caption live on #480 / #482 | One catalog row per corroborated org; companion caption is hint-only until bound | -| Event Lineage evidence | Channel evidence and Allen relations live on #387 / #484 | Persist channel scores, explain them in the popup, never invent a fused score | -| Scientific measurement | Durable accepted TEPP receipts and LineageWeave #614's exact accepted snapshot/cutoff/run/pair-count consumer are protected; TEPP #237 remains open, so no registered producer artifact exists yet. #387 removes inferred/default persistence weights, but several older reconstruction tests still pass hand-authored numeric dictionaries that are not estimator evidence | Land TEPP #237 through its protected gate, then replace remaining reconstruction-test constants with provenance-bearing fast-mlsirm estimates over synthetic fixtures. Retain true-parameter RMSE recovery as the acceptance bar | -| Asynchronous authorization | Protected `main` rebuilds Global Ask worker scope after the bearer token leaves the request; #468 now persists exact Keyverse organization/process-unit scope in 3NF child tables and intersects it with current affiliations | Land #468 through the protected gate; prove a second affiliation and a revoked process unit cannot widen delayed-job evidence | +| Event Lineage evidence | Channel evidence and Allen relations were delivered by merged #387 / #484 | Persist channel scores, explain them in the popup, never invent a fused score | +| Scientific measurement | Durable accepted TEPP receipts, LineageWeave #614's exact accepted snapshot/cutoff/run/pair-count consumer, and TEPP #237's criterion-anchor wire contract are protected. #237 intentionally does not implement the estimator, so no emitted digest-bound terminal artifact exists yet. Merged #387 removes inferred/default persistence weights, but several older reconstruction tests still pass hand-authored numeric dictionaries that are not estimator evidence | Implement and verify the TEPP estimator behind the protected #237 contract, then replace remaining reconstruction-test constants with provenance-bearing fast-mlsirm estimates over synthetic fixtures. Retain true-parameter RMSE recovery as the acceptance bar | +| Python mathematical-compute boundary | Protected `main` and #667 still execute residual construction, complete-case selection, NumPy SVD, Gabriel coordinates, distances, reconstruction, cross share, inertia, and coverage in `lineageweave/leftover_pairs.py`; therefore the candidate-stack deletion must not be described as shipped. Open parent #640 delegates the interaction map to fast-mlsirm but remains non-default delivery. fast-mlsirm draft #1417 exact head `e2d2ac27` defines the versioned Rust/PyO3 residual-interaction-map envelope; child draft #1457 exact `5c7dc9ea` adds owner-computed explained share with Rust/PyO3/Python parity, and LineageWeave #728 pins that exact candidate while performing projection only. Both drafts still require protected upstream acceptance and an immutable released pin before #640 reaches `main`. Active debt also remains in period-report GRM/GPCM matrix construction and theta summaries, Knowledge Graph random-walk vector iteration, channel-weight estimation, fusion normalization/contribution recomputation, and lexical overlap ranking. fast-mlsirm #1445 exact head `0b3a794e` supplies the separate Rust finite-population design artifact; #702 exact head `eda7083b` replays it but neither candidate is protected delivery. RankWeave #41 exact head `21fed0dc` proposes the shared Rust/PyO3 retrieval calculation boundary and likewise remains unshipped | Land fast-mlsirm #1417 then #1457 through protected gates and publish an immutable release before #640/#728 reaches `main`; LineageWeave must retain only validation, identifiers, persistence projection, API, and presentation. Land #1445/#702 before corpus inference and RankWeave #41 before vector ranking consumption. Add graph-ranking, fusion-contribution, and estimator/variance/interval Rust artifacts at their owning boundaries; require CPU/GPU parity and true-parameter recovery where applicable | +| Failed measurement recovery | A Failed measurement/topic-lineage run is immutable, but the prior UI delegated recovery and exposed no product retry action | Request a new authorized current-snapshot run, submit it through the existing outbox/TEPP boundary, retain the Failed history, and expose the action in all five locales | +| Asynchronous authorization | Protected `main` includes #468's 3NF Keyverse organization/process-unit scope and rebuilds Global Ask worker authorization after the bearer token leaves the request | Prove on the exact release head that a second affiliation and a revoked process unit cannot widen delayed-job evidence | | Planned-facility intent | Planned-facility relationship intent remains only on closed, unmerged #490; earlier stack-only merges were not protected delivery | Recreate the evidence-backed slice on a current base and land through protected `main` before a release claim | -| Accessibility and responsive UX | #602 delivered base post-detail modal semantics; #605 adds selected-post refocus, collapsed/hidden/inert/CSS-invisible focus exclusion across both modal types, readable evidence separators, focused tests, and desktop/mobile Storybook screenshots | Land #605 through the protected gate, then complete screen-reader and authenticated Playwright acceptance on the exact release head | +| Accessibility and responsive UX | Protected-main #602 and #605 deliver modal semantics, selected-post refocus, collapsed/hidden/inert/CSS-invisible focus exclusion, readable evidence separators, focused tests, and desktop/mobile Storybook screenshots | Complete screen-reader and authenticated Playwright acceptance on the exact release head | | Design tokens and repeated objects | Token extraction started; sanitized Figma Event Lineage desktop/mobile frames exist, while other repeated product surfaces remain incomplete | Tokens in CSS + Storybook stories for board, popup, DAG, Ask, calendar, forms, charts; same-viewport Figma/runtime visual comparison before release | -| Frontend delivery performance | #644 implements a native dynamic-import boundary for conditional workspace surfaces and retains accessible loading/error states; exact-head checks passed but the PR is not protected-main evidence | Merge #644 normally, rebuild the protected-main production bundle, and retain the measured chunk inventory rather than raising the warning limit | -| External integrations | Search, Zotero, calendar, Keyverse, orchestrator, RankWeave, ThreadWeave, TEPP, DiskSage, wardnet | Provider conformance, failure/reconciliation behavior, and provenance-bearing integration evidence | +| Frontend delivery performance | PR #644 exact head `f11e77d1` splits the real conditional workspace surfaces with native dynamic imports; 105 focused tests and the production build pass without the former large-chunk warning | Land #644 through protected gates, then repeat loading/error accessibility and bundle evidence on the exact release head | +| External integrations | Search, Zotero, calendar, Keyverse, orchestrator, RankWeave, ThreadWeave, TEPP, disksage, wardnet | Provider conformance, failure/reconciliation behavior, and provenance-bearing integration evidence | | MSA / modular reuse | LineageWeave must run standalone and as a consumer of org packages | Do not reimplement RankWeave/TEPP/orchestrator/ThreadWeave/Keyverse; fix upstream and PR there | -| Product contract authority | The current LineageWeave PRD records exact-case ecosystem authorities. TEPP, fast-mlsirm, keyverse, ThreadWeave, and RankWeave PR #41 have standalone PRDs; RankWeave's remains unmerged. contextual-orchestrator, disksage, and wardnet still rely on product/architecture documents, and naruon has only a scoped Topic Intelligence PRD | Keep ADRs normative, preserve canonical repository case in machine references, land the pending PRDs, and add standalone PRDs in each remaining owning repository before cross-product release claims exceed its documented boundary | -| Release quality | PR #660 is now on protected `main`; its pre-merge full Python suite passed 1,352 tests with 17 skips, but release-wide frontend, Storybook, security, browser, and runtime acceptance remain unproven on one exact protected head | Repository-wide coverage, docstrings, Storybook, security, browser, and release evidence on one exact head | +| Accelerator runtime ownership | ADR 0076/0208 already prohibit local model and mathematical ownership; ADR 0237 defines MLX as a native orchestrator-side service and TEPP/fast-mlsirm CUDA/OpenCL/CPU profiles as scientific-compute-owner deployments, so LineageWeave Compose remains device-neutral. contextual-orchestrator #857 proposes its Rust token/vector boundary, while RankWeave #41/#48 propose the Rust retrieval calculation boundary; all remain candidates until protected delivery | TEPP and fast-mlsirm must publish deterministic CPU recovery plus conformance evidence for every advertised CUDA/OpenCL profile; contextual-orchestrator must prove native MLX and embedding capability through its provider-neutral health/contract boundary; RankWeave must publish its reviewed Rust artifact. LineageWeave accepts only versioned, provenance-bearing envelopes and fails closed when an owner is unavailable | +| Product contract authority | This branch recreates the first LineageWeave PRD after superseded #613 closed without merge and records an exact-case ecosystem authority register; TEPP, fast-mlsirm, keyverse, and ThreadWeave have standalone PRDs, while contextual-orchestrator, RankWeave, disksage, and wardnet currently rely on product-planning/architecture documents and naruon has only a scoped Topic Intelligence PRD | Land the LineageWeave PRD, keep ADRs normative, and add standalone PRDs in each owning repository before making cross-product release claims beyond its documented boundary | +| Release quality | Protected merges #660 and #659 are represented by `main@494b54e2`; the #660 stacked tree passed the complete Python suite (1,352 passed, 17 skipped) before protected delivery. Frontend/Storybook, security, browser, and authorized-runtime evidence remain separate gaps | Repository-wide coverage, frontend/Storybook, security, browser, and authorized-runtime evidence on one exact head | | PII | Masking would paralyze the product; ADR 0001 forbids identifying artifacts in git | ABAC + authorized runtime; synthetic fixtures in git; no mask-in-place that drops names the operator must read | | Database | PostgreSQL, 3NF, snake_case ≥ two words, hot-partition and lock policy | No file DBs; read/write split if lock management fails; whitelist every migration | -### 5.1 Closed PR #490 decomposition (issue #611) - -Protected `main` at `04e6b610` and the three open PRs present during the initial -decomposition were rechecked; the later audit snapshot above includes #631 -itself as the fourth open PR. Protected `main` contains none of PR #490. That PR remains -closed, unmerged branch evidence; its ADR 0133–0137 files are not normative and -its 321-file tree must not be replayed. Current-main code and schema searches -give this delivery matrix: - -| Closed-branch decision | Current-main classification | Smallest remaining delivery | -| --- | --- | --- | -| ADR 0133 source-reference research | Partial foundation: protected `main` has the self-hosted SearXNG relation-verification client and fail-closed configuration, but it verifies an already extracted relation. It has no source-unit/image-region lead, cited-resource retrieval, claim judgment, or normalized research citation workflow | One post-scoped lead-to-citation slice that reuses the self-hosted SearXNG search boundary, adds public-target SSRF/redirect rejection for result retrieval, and judges through contextual-orchestrator with explicit unavailable outcomes | -| ADR 0134 token-backed exception messages | Partial: sanitized next-action failures exist, but no shared token-backed exception component or complete Storybook error inventory exists | Migrate one existing unavailable flow to one shared accessible alert and verify its success, unavailable, and retry states | -| ADR 0135 kind/status-exact analysis actions | Partial: protected `main` has kind-aware start/retry controls plus normative analysis-run, TEPP, cutoff-body, and channel-evidence contracts; it does not contain the closed branch's unified guidance component or its full kind × status interaction inventory | Test the current run-kind/status matrix first, then add only a proven missing state/control pair rather than copying the closed-branch function | -| ADR 0136 per-post Ask history | Partial: `post_chat_result` / `post_chat_citation`, the authorized post Chat API, and its linear exchange history are on protected `main`. Account-and-post-scoped sessions, ordered turns, list/select/new controls, and batched citation reauthorization are not | Define the 3NF account/post session boundary, bounded batch reauthorization, and one authorized list/load/write path before adding the conversation picker | -| ADR 0137 cross-post customer identity | Partial foundation: protected `main` preserves source customer hints and has corporate-catalog unique/miss/tie safeguards, but it has no normalized cross-post customer-identity judgment, supporting-post binding, or corporate-name-history workflow | Add only after external corroboration, orchestrator judgment, TEPP ordering, and unique-catalog fail-close can be verified together; never promote a one-post hint | - -This matrix satisfies only #611's current-main inventory step. Issue #611 -remains open: every unmet criterion above still needs a focused regression test -and exact-head current-main implementation PR before its acceptance criteria -are satisfied. No stale check, review, or implementation is transferred from -#490. - ## 6. UI-UX acceptance inventory (must be defined, reviewed, applied, audited) Each item needs a Storybook scene, an edge-case story, and an automated check @@ -421,12 +637,13 @@ before a commercial release claim. Figma File ID `1Su3lDRmiZdcUs47t1QwIX`. | --- | --- | --- | | Accessibility | Partial labels/roles on board, popup, login | WCAG 2.2 AA on login, board, popup, Ask, calendar, admin; focus order; live regions | | Touch & Interaction | Click-first popup and lists | 44px targets, swipe/escape to dismiss popup, no hover-only actions | -| Performance | Board caps and hint render limits exist | Interaction-to-next-paint on board search, DAG, Ask; no N+1 (#358) | +| Performance | Board caps exist, and closed issue #358 delivered batched persisted-Ask reauthorization | Measure interaction-to-next-paint on board search, DAG, and Ask against an exact-head authenticated workload | | Style Selection | Korean UI standards merged (#347) | Tokenized light/dark; Anti-Slop-UI density; no decorative noise | -| Layout & Responsive | Desktop popup shell | 402px-class phone layout; stacked GNB; readable DAG | +| Layout & Responsive | Desktop popup shell; this candidate stacks analysis-run caption, document count, and next action at 390px and retains explicit grid columns at 1440px | Complete 402px-class phone acceptance for remaining panels; stacked GNB; readable DAG | | Typography & Color | Badge tokens extracted | Contrast on badges, links, error/status; no raw hex in components | +| Localization | Closed-unmerged #686 contains a five-locale customer-copy audit but is not delivery evidence. The open #667 candidate includes five-locale analysis-run guidance, completeness tests, and desktop/mobile Storybook evidence without exposing implementation ownership | Port any still-required #686 behavior into an open current-main candidate, land it and #667 through exact-head protected gates, then repeat authenticated locale switching and assistive-technology acceptance on the release head | | Animation | Minimal | Reduced-motion; no blocking animation on evidence open | -| Forms & Feedback | Login, Ask, tickets, admin brand | Inline validation, next-action copy, unavailable vs failed distinction | +| Forms & Feedback | Login, Ask, tickets, admin brand; this candidate gives all four Cancelled analysis kinds an evidence-safe next action | Inline validation and unavailable-vs-failed acceptance across remaining workflows | | Navigation Patterns | Board / customers / calendar / Ask / admin | Deep-link post + OIDC return URL (#426); bookmarkable Ask | | Charts & Data | Period reports, leftover pairs, Rankings, DAG | Honest empty/unavailable; no invented theta; Storybook chart states | @@ -441,9 +658,9 @@ of leverage; open connector PRs there when the defect is upstream: 4. **TEPP** — calibrated measurement; persist receipts; no local reimplementation. 5. **fast-mlsirm** — GRM/GPCM/CAT/FIPC recovery tests (#451–#454) must stay true-parameter RMSE. 6. **ThreadWeave** — tree assembly. -7. **Naruon** — calendar and email/project lineage projection (#336, #338, #355). -8. **DiskSage / wardnet** — storage and network policy as needed. -9. **ContextualWisdomLab/.github** — required review workflows (OpenCode, Strix, Noema) and the LineageWeave hourly caller (#1259). If stacked PRs miss central review or coverage-evidence fails on pnpm 9 (`--trust-lockfile` is pnpm 11.3) or a missing Vitest coverage provider, fix the org workflow (#1258), not a local bypass. +7. **Naruon** — calendar projection delivered through merged #355/closed #336; email/project lineage projection remains open as #338. +8. **disksage / wardnet** — storage and network policy as needed. +9. **ContextualWisdomLab/.github** — required review workflows (OpenCode, Strix, Noema) and the LineageWeave hourly caller (#1288, current head `c78ae017`). Its current-main reconciliation preserves the pinned contextual-orchestrator sidecar, loopback gateway contract, provider-key isolation, existing review/OIDC mutation credentials, and the action-oriented customer-copy rule; 67 focused central tests pass, while exact-head hosted checks and independent re-review remain pending. If stacked PRs miss central review or coverage-evidence fails on pnpm 9 (`--trust-lockfile` is pnpm 11.3) or a missing Vitest coverage provider, fix the org workflow (#1258), not a local bypass. ## 8. Public ontology publication boundary @@ -453,11 +670,18 @@ of leverage; open connector PRs there when the defect is upstream: - Pull requests validate only. Only protected `main` may publish, and the generated-directory marker, linked-IRI, duplicate-fragment, symlink, and source-overlap checks fail closed. -- The lowercase knowledge-graph namespace and repository-case support-profile - namespace remain distinct until issue #372 delivers a versioned migration - and compatibility decision; this publication PR rewrites neither identity. -- Until the protected deployment and exact URL checks succeed, the public - ontology endpoint remains unavailable and must not be represented as live. +- ADR 0207 and closed issue #372 make the repository-case namespace canonical; + ADR 0236 records that the lowercase namespace paths return HTTP 404. They are + deprecated compatibility identifiers mapped by the term-kind-safe public + `namespace-compatibility.ttl` artifact, not a second served namespace. +- The public index, `ontology.ttl`, SHACL graph, and manifest return HTTP 200. + Their Turtle SHA-256 values match protected `main@494b54e2` exactly + (`c5a8c147…` ontology; `a57f274e…` shapes). Ontology Pages workflow run + `32925410179` completed successfully on exact protected head + `494b54e2245040bcf02b45376f221c37cd437e76`; a fresh live fetch reproduced + both hashes. The canonical directory redirects to its published index, while + the deprecated lowercase namespace path remains an intentional HTTP 404 per + ADR 0236. ## 9. Evidence boundaries @@ -482,19 +706,18 @@ Process every open PR in ascending number order, considering leverage; for each: check reviews → repair → re-verify Checks → merge → continue. Checks and review latency are never blockers — keep working while they settle. -1. Revalidate Strix after merged ContextualWisdomLab/.github#1320, reconcile - open .github#1263, and land the atomic hourly LineageWeave caller in open - .github#1288 only through their protected gates. -2. Process main-targeted PRs #629, #631, #632, #639, #640, #643, #644, #657, - #658, #659, #660, and #663 only after each exact head shows terminal green - required checks plus current-head independent approval. Treat #666's - non-default-branch merge only as part of #663's combined candidate and - collect all protected evidence on #663's exact head. -3. While hosted checks or independent reviews wait, resume user-visible gaps - from §5 in leverage order: - external semantic verification (#272), Naruon calendar (#355/#336), and - authenticated operations/ontology publication acceptance. Event Lineage - evidence shipped in merged PR #387 and closed issue #274 is not an open gap. +1. Revalidate Strix after protected ContextualWisdomLab/.github#1320, reconcile + .github#1263, and land the atomic hourly LineageWeave caller in .github#1288 after its current-head independent approval and checks complete. +2. Process the current section 1 inventory in ascending PR order after + dependency review. #677, #688, #689, #690, #692, #694, #697, and #699 are already + merged only into non-default parent branches; their acceptance travels + through #667, #640, or #693 as recorded in section 1. No child head or non-default merge may substitute for protected + delivery of its base. + Merge only after each exact head shows terminal green required checks plus + current-head independent approval. +3. After the queue drains, resume user-visible gaps from §5 in leverage order: + Event Lineage evidence (merged #387; historical issue #274), Naruon calendar (#355), and + authenticated operations/ontology publication acceptance. 4. Rename remaining `[Buyer Gap]` issue titles to neutral product-object naming per repository convention (no "Buyer" for internal objects). 5. Keep psychometric tests as true-parameter recovery (RMSE); never fixture @@ -515,8 +738,8 @@ review latency are never blockers — keep working while they settle. - Orchestrator / paper-grounded models: ADR 0015, ADR 0076 (Fugu, TRINITY, Conductor) - Ontology / PROV-O / SKOS: ADR 0004, ADR 0011, issue #372 - Analysis runs / TEPP: ADR 0013–0023, issue #79 / #277 -- Calendar / Naruon: issues #336 / #338, PR #355, operator consumption v2.17.0 -- Ask Agent: issues #269–#272, #358–#363 +- Calendar / Naruon: closed issue #336, open issue #338, merged PR #355, operator consumption v2.17.0 +- Ask Agent: open issues #269–#272; closed performance/ontology issues #358 and #363 are delivery history, not active work Citations in doctoring and ADRs use APA 7th. Do not invent a heuristic where the papers leave the decision undecided. diff --git a/docs/screenshots/analysis-run-guidance-ko-desktop.png b/docs/screenshots/analysis-run-guidance-ko-desktop.png new file mode 100644 index 000000000..65286d3f1 Binary files /dev/null and b/docs/screenshots/analysis-run-guidance-ko-desktop.png differ diff --git a/docs/screenshots/analysis-run-guidance-ko-mobile.png b/docs/screenshots/analysis-run-guidance-ko-mobile.png new file mode 100644 index 000000000..7d0b945de Binary files /dev/null and b/docs/screenshots/analysis-run-guidance-ko-mobile.png differ diff --git a/docs/screenshots/cancelled-analysis-runs-desktop.png b/docs/screenshots/cancelled-analysis-runs-desktop.png new file mode 100644 index 000000000..f1c0f5d56 Binary files /dev/null and b/docs/screenshots/cancelled-analysis-runs-desktop.png differ diff --git a/docs/screenshots/cancelled-analysis-runs-mobile.png b/docs/screenshots/cancelled-analysis-runs-mobile.png new file mode 100644 index 000000000..64ed8bd6d Binary files /dev/null and b/docs/screenshots/cancelled-analysis-runs-mobile.png differ diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 81ab3a8af..d83e579b1 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -32,5 +32,11 @@ https://www.w3.org/community/reports/design-tokens/CG-FINAL-format-20251028/ Storybook. (2026). *Storybook for React & Vite*. https://storybook.js.org/docs/get-started/frameworks/react-vite +`Workspace/ChatPanel` covers seeded-only, saved-history, narrow mobile, and +provider-neutral unavailable states for the ADR 0235 list/select/new +conversation controls. The unavailable scene keeps saved evidence visible and +tells the reader to review it before retrying; its 1440x900 and 390x844 renders +were inspected on 2026-08-27. + World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ diff --git a/frontend/src/AnalysisRunGuidance.stories.tsx b/frontend/src/AnalysisRunGuidance.stories.tsx new file mode 100644 index 000000000..e6eb8e6dc --- /dev/null +++ b/frontend/src/AnalysisRunGuidance.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { + analysisRunText, + type AnalysisRunCopyKey, +} from "./analysisRunI18n"; +import { getLocale, setLocale, useLocale } from "./i18n"; +import "./App.css"; + +const RUN_STATES: Array<{ + label: string; + key: AnalysisRunCopyKey; +}> = [ + { label: "계보 재구성 · 대기", key: "pendingLineage" }, + { label: "측정 · 실패", key: "failedMeasurement" }, + { label: "토픽 계보 · 진행 중", key: "running" }, + { label: "기간 보고서 · 취소", key: "cancelledReport" }, + { label: "측정 · 결과 없음", key: "emptyMeasurement" }, + { label: "토픽 계보 · 선택된 글", key: "corpusPendingTopicLineage" }, + { label: "측정 · 다시 실행", key: "retryMeasurement" }, + { label: "토픽 계보 · 다시 실행", key: "retryTopicLineage" }, +]; + +function AnalysisRunGuidanceInventory() { + useLocale(); + return ( +
+

분석 실행 안내

+ +
+ ); +} + +const meta = { + title: "Workspace/AnalysisRunGuidance", + component: AnalysisRunGuidanceInventory, + parameters: { layout: "padded" }, + beforeEach: () => { + const previous = getLocale(); + setLocale("ko"); + return () => setLocale(previous); + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const StatusAndCorpusMatrix: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("‘측정 다시 실행’을 선택하세요.", { exact: false })).toBeVisible(); + await expect(canvas.getByText("이 실행을 시작해 글을 토픽 계보 분석 대상으로 제출하세요. 결과가 보장되지는 않습니다.")).toBeVisible(); + await expect(canvas.getByText("측정 다시 실행")).toBeVisible(); + }, +}; + +export const Phone: Story = { + ...StatusAndCorpusMatrix, + parameters: { viewport: { defaultViewport: "mobile1" } }, +}; diff --git a/frontend/src/App.css b/frontend/src/App.css index 901b17b1a..76eae74ce 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -972,6 +972,32 @@ gap: 0.5rem; } +.analysis-run-item { + display: grid; + gap: 0.75rem; +} + +.analysis-run-item .post-meta { + overflow-wrap: anywhere; + word-break: keep-all; +} + +.analysis-run-item.has-document-count.has-next-action { + grid-template-columns: minmax(12rem, 1fr) auto minmax(18rem, 2fr); +} + +.analysis-run-item.has-document-count:not(.has-next-action) { + grid-template-columns: minmax(12rem, 1fr) auto; +} + +.analysis-run-item.has-next-action:not(.has-document-count) { + grid-template-columns: minmax(12rem, 1fr) minmax(18rem, 2fr); +} + +.analysis-run-item:not(.has-document-count):not(.has-next-action) { + grid-template-columns: 1fr; +} + .ranking-hit { flex-direction: column; align-items: stretch; @@ -1022,6 +1048,36 @@ gap: 0.5rem; } +.chat-history-controls { + display: flex; + align-items: end; + gap: var(--space-control-gap); + margin-bottom: var(--space-panel-block); +} + +.chat-history-controls label { + display: grid; + flex: 1; + gap: var(--space-control-gap); +} + +.chat-history-controls select { + min-height: var(--size-control-min); + width: 100%; +} + +.chat-history-controls > button { + min-height: var(--size-control-min); + white-space: nowrap; +} + +@media (max-width: 640px) { + .chat-history-controls { + align-items: stretch; + flex-direction: column; + } +} + .chat-input-row input { flex: 1; padding: 0.5rem; @@ -1151,6 +1207,14 @@ align-items: flex-start; gap: 0.5rem; } + + .analysis-run-item, + .analysis-run-item.has-document-count.has-next-action, + .analysis-run-item.has-document-count:not(.has-next-action), + .analysis-run-item.has-next-action:not(.has-document-count), + .analysis-run-item:not(.has-document-count):not(.has-next-action) { + grid-template-columns: 1fr; + } } .ontology-explorer { diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 2dee4513d..b5b9aebcc 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import App from "./App"; +import type { AnalysisRunKindCode } from "./api"; import { setLocale } from "./i18n"; import { OIDC_RETURN_URL_STORAGE_KEY } from "./oidcReturnUrl"; @@ -101,11 +102,15 @@ describe("App, authenticated", () => { searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; failedLineageRun?: boolean; + failLineageCreateOnce?: boolean; + failMeasurementStartOnce?: boolean; + secondFailedTeppRun?: boolean; runningLineageRun?: boolean; failedReportRun?: boolean; succeededReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; + cancelledRunKind?: AnalysisRunKindCode; pluralAffiliations?: boolean; deferMe?: boolean; deferPostOne?: boolean; @@ -122,6 +127,8 @@ describe("App, authenticated", () => { askDelivery?: boolean; lineageIsolationReason?: "comparison_candidates_available" | "no_comparison_group"; }): ReturnType & { releaseMe: () => void; releasePostOne: () => void } { + let lineageCreateFailed = false; + let measurementStartFailed = false; const statusLabel: Record = { open: "Open", in_progress: "In progress", @@ -161,6 +168,27 @@ describe("App, authenticated", () => { releasePostOne = resolve; }) : Promise.resolve(); + const cancelledRun = options?.cancelledRunKind + ? { + analysis_run_id: "run-demo-cancelled", + run_kind_code: options.cancelledRunKind, + run_kind_label: { + analysis_run_lineage: "Lineage reconstruction", + analysis_run_tepp: "TEPP measurement", + analysis_run_topic_lineage: "Topic lineage", + analysis_run_report: "Period report", + }[options.cancelledRunKind], + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_cancelled" as const, + status_label: "Cancelled", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:42:00Z", + source_counts: [], + visible_posts: [], + } + : null; const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -246,7 +274,8 @@ describe("App, authenticated", () => { return Promise.resolve( new Response( JSON.stringify({ - detail: "Commitment extraction is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", + detail: + "Commitment extraction is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", }), { status: 503, headers: { "Content-Type": "application/json" } }, ), @@ -269,6 +298,9 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", has_commitment: true, ticket }), ); } + if (url.endsWith("/api/analysis-runs/run-demo-cancelled") && cancelledRun) { + return Promise.resolve(jsonResponse(cancelledRun)); + } if (url.endsWith("/api/analysis-runs/run-demo-report")) { const reportSucceeded = !options?.failedReportRun; return Promise.resolve( @@ -381,6 +413,7 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", + scope_corporate_entity_id: "corp-demo", status_code: teppStatus, status_label: teppLabel, knowledge_cutoff: "2026-01-12T12:00:00Z", @@ -604,6 +637,7 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", + scope_corporate_entity_id: "corp-demo", status_code: "analysis_status_failed", status_label: "Failed", failure_code: "tepp_not_available", @@ -641,21 +675,97 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/analysis-runs/run-demo-tepp-retry/start") && method === "POST") { + if (options?.failMeasurementStartOnce && !measurementStartFailed) { + measurementStartFailed = true; + return Promise.resolve( + new Response(JSON.stringify({ detail: "The response was interrupted." }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + ); + } + createdPendingTepp = { + analysis_run_id: "run-demo-tepp-retry", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + scope_corporate_entity_id: "corp-demo", + status_code: "analysis_status_failed", + status_label: "Failed", + failure_code: "tepp_not_available", + knowledge_cutoff: "2026-01-13T12:00:00Z", + requested_at: "2026-01-13T12:01:00Z", + source_counts: [], + visible_posts: [], + }; + return Promise.resolve(jsonResponse(createdPendingTepp)); + } + if (url.endsWith("/api/analysis-runs/run-demo-tepp-two")) { + return Promise.resolve( + jsonResponse({ + analysis_run_id: "run-demo-tepp-two", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Other Demo Corp", + scope_corporate_entity_id: "corp-other", + status_code: "analysis_status_failed", + status_label: "Failed", + failure_code: "tepp_not_available", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:39:00Z", + source_counts: [], + visible_posts: [], + }), + ); + } if (url.endsWith("/api/analysis-runs") && method === "POST") { const payload = init?.body ? JSON.parse(String(init.body)) : {}; - if (payload.run_kind_code === "analysis_run_tepp" || payload.run_kind_code === "analysis_run_report") { + if ( + payload.run_kind_code === "analysis_run_lineage" && + options?.failLineageCreateOnce && + !lineageCreateFailed + ) { + lineageCreateFailed = true; + return Promise.resolve( + new Response(JSON.stringify({ detail: "The response was interrupted." }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + ); + } + if (payload.run_kind_code === "analysis_run_report") { return Promise.resolve( new Response( JSON.stringify({ - detail: - payload.run_kind_code === "analysis_run_tepp" - ? "Connect a TEPP transport from a Failed TEPP row; this endpoint does not invent a measurement." - : "Rebuild the period report from the Reports panel.", + detail: "Rebuild the period report from the Reports panel.", }), { status: 422, headers: { "Content-Type": "application/json" } }, ), ); } + if (payload.run_kind_code === "analysis_run_tepp") { + createdPendingTepp = { + analysis_run_id: "run-demo-tepp-retry", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + scope_corporate_entity_id: "corp-demo", + status_code: "analysis_status_pending", + status_label: "Pending", + knowledge_cutoff: "2026-01-13T12:00:00Z", + requested_at: "2026-01-13T12:01:00Z", + source_counts: [], + visible_posts: [], + }; + return Promise.resolve(new Response(JSON.stringify(createdPendingTepp), { status: 201 })); + } const created = { analysis_run_id: "run-demo-lineage-pending", run_kind_code: "analysis_run_lineage", @@ -686,8 +796,27 @@ describe("App, authenticated", () => { return Promise.resolve( jsonResponse({ analysis_runs: [ + ...(cancelledRun ? [cancelledRun] : []), ...(createdPendingLineage ? [createdPendingLineage] : []), ...(createdPendingTepp ? [createdPendingTepp] : []), + ...(options?.secondFailedTeppRun + ? [ + { + analysis_run_id: "run-demo-tepp-two", + run_kind_code: "analysis_run_tepp" as const, + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Other Demo Corp", + scope_corporate_entity_id: "corp-other", + status_code: "analysis_status_failed" as const, + status_label: "Failed", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:39:00Z", + source_counts: [], + }, + ] + : []), { analysis_run_id: "run-demo-lineage", run_kind_code: "analysis_run_lineage", @@ -725,6 +854,7 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", + scope_corporate_entity_id: "corp-demo", status_code: options?.succeededTeppRun ? "analysis_status_succeeded" : options?.pendingTeppRun @@ -1344,7 +1474,8 @@ describe("App, authenticated", () => { return Promise.resolve( new Response( JSON.stringify({ - detail: "Keymen extraction is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", + detail: + "Keymen extraction is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", }), { status: 503, headers: { "Content-Type": "application/json" } }, ), @@ -1357,7 +1488,8 @@ describe("App, authenticated", () => { return Promise.resolve( new Response( JSON.stringify({ - detail: "Post evaluation is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", + detail: + "Post evaluation is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", }), { status: 503, headers: { "Content-Type": "application/json" } }, ), @@ -1629,6 +1761,7 @@ describe("App, authenticated", () => { relationship_label: "Voice of Customer", verification_status_code: "verify_pending", verification_evidence_url: null, + verification_evidence_post_id: "post-1", corporate_entity_id: "corp-1", ...demoOrgAlias, }, @@ -1648,7 +1781,10 @@ describe("App, authenticated", () => { if (options?.searchUnavailable) { return Promise.resolve( new Response( - JSON.stringify({ detail: "Relation verification is unavailable: set SEARXNG_BASE_URL" }), + JSON.stringify({ + detail: + "Relation verification is temporarily unavailable. Retry in a moment. If this continues, contact your workspace administrator.", + }), { status: 503, headers: { "Content-Type": "application/json" } }, ), ); @@ -1707,7 +1843,8 @@ describe("App, authenticated", () => { return Promise.resolve( new Response( JSON.stringify({ - detail: "Post chat is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", + detail: + "Post chat is unavailable. Retry in a moment. If this continues, contact your workspace administrator.", }), { status: 503, headers: { "Content-Type": "application/json" } }, ), @@ -2628,7 +2765,11 @@ describe("App, authenticated", () => { await userEvent.click(screen.getByRole("button", { name: /^ask$/i })); await waitFor(() => - expect(screen.getByText("Chat is temporarily unavailable. Saved evidence is still available.")).toBeInTheDocument(), + expect( + screen.getByText( + "Chat is temporarily unavailable. Review the saved evidence below, then retry in a moment.", + ), + ).toBeInTheDocument(), ); expect(screen.queryByText(/HTTP 503/)).not.toBeInTheDocument(); expect(screen.queryByPlaceholderText(/what happened/i)).not.toBeInTheDocument(); @@ -2652,7 +2793,11 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: /evaluate post/i })); await waitFor(() => - expect(screen.getByText("Evaluation is temporarily unavailable. Saved evidence is still available.")).toBeInTheDocument(), + expect( + screen.getByText( + "Evaluation is temporarily unavailable. Review the saved evidence below, then retry in a moment.", + ), + ).toBeInTheDocument(), ); expect(screen.queryByText(/HTTP 503/)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /evaluate post/i })).not.toBeInTheDocument(); @@ -2666,7 +2811,11 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: /extract keymen/i })); await waitFor(() => - expect(screen.getByText("Keymen extraction is temporarily unavailable. Saved evidence is still available.")).toBeInTheDocument(), + expect( + screen.getByText( + "Keymen extraction is temporarily unavailable. Review the saved evidence below, then retry in a moment.", + ), + ).toBeInTheDocument(), ); expect(screen.queryByText(/HTTP 503/)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /extract keymen/i })).not.toBeInTheDocument(); @@ -2681,7 +2830,9 @@ describe("App, authenticated", () => { await waitFor(() => expect( - screen.getByText("Commitment derivation is temporarily unavailable. Saved evidence is still available."), + screen.getByText( + "Commitment derivation is temporarily unavailable. Review the saved evidence below, then retry in a moment.", + ), ).toBeInTheDocument(), ); expect(screen.queryByText(/HTTP 503/)).not.toBeInTheDocument(); @@ -2696,7 +2847,11 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: /verify against web search/i })); await waitFor(() => - expect(screen.getByText("Verification unavailable (search is not configured).")).toBeInTheDocument(), + expect( + screen.getByText( + "Verification is temporarily unavailable. Retry in a moment; if this continues, contact your workspace administrator.", + ), + ).toBeInTheDocument(), ); expect(screen.queryByText(/HTTP 503/)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /verify against web search/i })).not.toBeInTheDocument(); @@ -2928,6 +3083,15 @@ describe("App, authenticated", () => { expect(screen.queryByRole("button", { name: "Counterparty org: Northridge Grid" })).not.toBeInTheDocument(); }); + it("opens a counterparty supporting post without exposing its storage boundary", async () => { + stubBackend(); + render(); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + expect(await screen.findByRole("button", { name: "Open supporting post" })).toBeInTheDocument(); + expect(screen.queryByText(/internal evidence/i)).not.toBeInTheDocument(); + }); + it("links a verification badge only for http(s) evidence URLs", async () => { stubBackend({ verificationEvidenceUrl: "https://example.test/searxng?q=Northridge" }); render(); @@ -3249,7 +3413,7 @@ describe("App, authenticated", () => { expect(list).toHaveTextContent("TEPP measurement · Failed · Demo Corp"); expect(list).toHaveTextContent("Period report · Succeeded · Demo Corp"); expect(list).toHaveTextContent( - "Open this run to see why it failed, then connect the measurement service and re-run.", + "Open this run to see why it failed, then choose Retry measurement.", ); expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); @@ -3347,7 +3511,7 @@ describe("App, authenticated", () => { ).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 measurement at this snapshot/i)).toBeInTheDocument(); expect(teppHistory).not.toHaveTextContent("Succeeded"); }); @@ -3399,7 +3563,44 @@ describe("App, authenticated", () => { expect(screen.queryByRole("heading", { name: "Body this run knew" })).not.toBeInTheDocument(); }); - it("tells a running lineage run to refresh the durable outbox", async () => { + it.each([ + ["analysis_run_lineage", "Request a new lineage reconstruction from a current snapshot."], + [ + "analysis_run_tepp", + "Ask an administrator to restore measurement and submit a new run from a current snapshot.", + ], + [ + "analysis_run_topic_lineage", + "Ask an administrator to restore topic-lineage analysis and submit a new run from a current snapshot.", + ], + ["analysis_run_report", "Rebuild the period report from a current snapshot."], + ] satisfies [AnalysisRunKindCode, string][])( + "gives a cancelled %s run its kind-exact next action", + async (runKindCode, nextAction) => { + stubBackend({ cancelledRunKind: runKindCode }); + render(); + + const cancelledRunButton = await screen.findByRole("button", { + name: /Open analysis run: .* · Cancelled · Demo Corp/, + }); + expect(cancelledRunButton).toHaveTextContent(`This run was cancelled. ${nextAction}`); + await userEvent.click(cancelledRunButton); + expect(screen.getAllByText(`This run was cancelled. ${nextAction}`)).not.toHaveLength(0); + expect(screen.queryByRole("button", { name: /Start/ })).not.toBeInTheDocument(); + }, + ); + + it("shows analysis-run next actions in the selected locale", async () => { + setLocale("ko"); + stubBackend({ cancelledRunKind: "analysis_run_lineage" }); + render(); + + expect(await screen.findByText( + "이 실행은 취소되었습니다. 현재 스냅샷에서 새 계보 재구성을 요청하세요.", + )).toBeInTheDocument(); + }); + + it("tells a running lineage run how to check for results", async () => { stubBackend({ runningLineageRun: true }); render(); @@ -3407,12 +3608,12 @@ describe("App, authenticated", () => { name: "Open analysis run: Lineage reconstruction · Running · Demo Corp", }); expect(lineageButton).toHaveTextContent( - "Refresh this run. Start already queued the work on the durable outbox.", + "This run is in progress. Refresh it to check for results.", ); await userEvent.click(lineageButton); expect(screen.getByRole("button", { name: "Start reconstruction" })).toBeInTheDocument(); expect( - screen.getAllByText("Refresh this run. Start already queued the work on the durable outbox."), + screen.getAllByText("This run is in progress. Refresh it to check for results."), ).not.toHaveLength(0); }); @@ -3432,7 +3633,7 @@ describe("App, authenticated", () => { ); expect(lineageButton).not.toHaveTextContent("measurement service"); expect(teppButton).toHaveTextContent( - "Open this run to see why it failed, then connect the measurement service and re-run.", + "Open this run to see why it failed, then choose Retry measurement.", ); expect(teppButton).not.toHaveTextContent("reconstruction"); }); @@ -3721,7 +3922,7 @@ describe("App, authenticated", () => { ).not.toBeInTheDocument(); expect( await screen.findByText( - "No posts were available at this cutoff for the period report. Open a later run, or ask an administrator to capture a newer snapshot.", + "No posts were available at this snapshot for the period report. Open a later run, or ask an administrator to capture a newer snapshot.", ), ).toBeInTheDocument(); }); @@ -3736,13 +3937,13 @@ describe("App, authenticated", () => { }), ); expect( - await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."), + await screen.findByText("Start this run to submit these posts for measurement. A result is not guaranteed."), ).toBeInTheDocument(); expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); expect(screen.queryByText(/this TEPP run measured/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 measurement" })).toBeInTheDocument(); }); it("starts a pending TEPP run through tepp_client and does not invent a theta", async () => { @@ -3754,7 +3955,7 @@ describe("App, authenticated", () => { name: "Open analysis run: TEPP measurement · Pending · Demo Corp", }), ); - await userEvent.click(screen.getByRole("button", { name: "Start TEPP measurement" })); + await userEvent.click(screen.getByRole("button", { name: "Start measurement" })); expect( await screen.findByRole("heading", { name: "TEPP measurement · Failed · Demo Corp" }), ).toBeInTheDocument(); @@ -3767,7 +3968,7 @@ describe("App, authenticated", () => { expect(startCall?.[1]?.method).toBe("POST"); }); - it("does not invent a Pending TEPP row from a Failed TEPP run", async () => { + it("requests and starts a new measurement from a Failed run", async () => { const fetchMock = stubBackend(); render(); @@ -3776,18 +3977,100 @@ describe("App, authenticated", () => { name: "Open analysis run: TEPP 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.", - ), - ).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(); + await userEvent.click(screen.getByRole("button", { name: "Retry measurement" })); + await waitFor(() => + expect( + fetchMock.mock.calls.some((call) => + String(call[0]).endsWith("/api/analysis-runs/run-demo-tepp-retry/start"), + ), + ).toBe(true), + ); + const createCall = fetchMock.mock.calls.find( + (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", + ); + expect(JSON.parse(String(createCall?.[1]?.body))).toMatchObject({ + run_kind_code: "analysis_run_tepp", + corporate_entity_id: "corp-demo", + }); expect( fetchMock.mock.calls.some( - (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", + (call) => + String(call[0]).endsWith("/api/analysis-runs/run-demo-tepp-retry/start") && + call[1]?.method === "POST", ), - ).toBe(false); + ).toBe(true); + }); + + it("does not reuse an interrupted lineage request key for measurement retry", async () => { + const fetchMock = stubBackend({ failLineageCreateOnce: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { name: "Request a lineage reconstruction" }), + ); + await waitFor(() => + expect(screen.getByRole("button", { name: "Request a lineage reconstruction" })).toBeEnabled(), + ); + await userEvent.click( + screen.getByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + }), + ); + await userEvent.click(screen.getByRole("button", { name: "Retry measurement" })); + + const createBodies = fetchMock.mock.calls + .filter( + (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", + ) + .map((call) => JSON.parse(String(call[1]?.body))); + expect(createBodies).toHaveLength(2); + expect(createBodies[0].run_kind_code).toBe("analysis_run_lineage"); + expect(createBodies[1].run_kind_code).toBe("analysis_run_tepp"); + expect(createBodies[1].idempotency_key).not.toBe(createBodies[0].idempotency_key); + }); + + it("retains each interrupted measurement retry key across run switches", async () => { + const fetchMock = stubBackend({ + failMeasurementStartOnce: true, + secondFailedTeppRun: true, + }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + }), + ); + await userEvent.click(screen.getByRole("button", { name: "Retry measurement" })); + await waitFor(() => + expect(screen.getByRole("button", { name: "Retry measurement" })).toBeEnabled(), + ); + await userEvent.click( + screen.getByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Other Demo Corp", + }), + ); + await userEvent.click(screen.getByRole("button", { name: "Retry measurement" })); + const originalRun = screen + .getAllByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + }) + .find((button) => button.textContent?.includes("3 documents")); + expect(originalRun).toBeDefined(); + await userEvent.click(originalRun!); + await userEvent.click(screen.getByRole("button", { name: "Retry measurement" })); + + const retries = fetchMock.mock.calls + .filter( + (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", + ) + .map((call) => JSON.parse(String(call[1]?.body))); + expect(retries).toHaveLength(3); + expect(retries[0].corporate_entity_id).toBe("corp-demo"); + expect(retries[1].corporate_entity_id).toBe("corp-other"); + expect(retries[1].idempotency_key).not.toBe(retries[0].idempotency_key); + expect(retries[2].corporate_entity_id).toBe("corp-demo"); + expect(retries[2].idempotency_key).toBe(retries[0].idempotency_key); }); it("does not tell a succeeded TEPP run to replace Failed", async () => { @@ -3800,7 +4083,7 @@ describe("App, authenticated", () => { }), ); expect( - await screen.findByText("These posts are the cutoff corpus this TEPP run measured."), + await screen.findByText("LineageWeave measured these posts in this run."), ).toBeInTheDocument(); expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fbba1d9f2..51615c510 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { AdminPanel } from "./components/AdminPanel"; import { LeftoverPairList } from "./components/LeftoverPairList"; import { WorkspaceCalendar } from "./components/WorkspaceCalendar"; import { focusedGraphMustReset } from "./focusedGraphSelection"; +import { analysisRunText } from "./analysisRunI18n"; import { useCallback, useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; @@ -27,6 +28,8 @@ import { fetchPostActivity, fetchPostBookmark, fetchPostChat, + fetchPostChatConversation, + fetchPostChatConversations, fetchPostAffiliateTree, fetchPostCounterparties, fetchPostEvaluation, @@ -58,6 +61,8 @@ import { type CalendarResponse, type ChatAnswer, type ChatExchange, + type PostAskConversationSummary, + type PostAskConversationPage, type CorporateEntityRef, type CustomerMasterEntity, type CustomerMasterResponse, @@ -125,7 +130,7 @@ import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { if (err instanceof BackendError && err.status === 503) { - return `${action} ${t("is temporarily unavailable.")} ${t("Saved evidence is still available.")}`; + return `${action} ${t("is temporarily unavailable.")} ${t("Review the saved evidence below, then retry in a moment.")}`; } return String(err); } @@ -157,7 +162,9 @@ function LanguageSwitcher({ accessToken }: { accessToken?: string }) { function searchUnavailableMessage(err: unknown): string { if (err instanceof BackendError && err.status === 503) { - return t("Verification unavailable (search is not configured)."); + return t( + "Verification is temporarily unavailable. Retry in a moment; if this continues, contact your workspace administrator.", + ); } return String(err); } @@ -257,7 +264,15 @@ function ChatCitations({ ); } -function ChatPanel({ +function appendUniqueConversations( + current: PostAskConversationSummary[], + incoming: PostAskConversationSummary[], +): PostAskConversationSummary[] { + const conversationIds = new Set(current.map((conversation) => conversation.conversation_id)); + return [...current, ...incoming.filter((conversation) => !conversationIds.has(conversation.conversation_id))]; +} + +export function ChatPanel({ postId, accessToken, nameFirstAsk, @@ -268,30 +283,117 @@ function ChatPanel({ }) { const [question, setQuestion] = useState(""); const [exchanges, setExchanges] = useState([]); + const [seededExchanges, setSeededExchanges] = useState([]); + const [conversations, setConversations] = useState([]); + const [conversationCursor, setConversationCursor] = useState(null); + const [conversationOlderCursor, setConversationOlderCursor] = useState(null); + const [conversationId, setConversationId] = useState(null); + const [historyError, setHistoryError] = useState(null); const [answer, setAnswer] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [conversationLoading, setConversationLoading] = useState(false); const [evidencePostId, setEvidencePostId] = useState(null); const [seededOnly, setSeededOnly] = useState(false); + const conversationListRequest = useRef(0); + const conversationRequest = useRef(0); + const askRequest = useRef(0); useEffect(() => { + let active = true; + ++conversationListRequest.current; + ++conversationRequest.current; + ++askRequest.current; + setQuestion(""); setExchanges([]); + setSeededExchanges([]); + setConversations([]); + setConversationId(null); + setHistoryError(null); + setConversationCursor(null); + setConversationOlderCursor(null); setAnswer(null); setError(null); + setLoading(false); + setConversationLoading(false); setSeededOnly(false); setEvidencePostId(null); fetchPostChat(accessToken, postId) - .then((history) => setExchanges(history.exchanges)) - .catch(() => setExchanges([])); + .then((history) => { + if (active) { + setSeededExchanges(history.exchanges); + setExchanges(history.exchanges); + } + }) + .catch(() => { if (active) setExchanges([]); }); + fetchPostChatConversations(accessToken, postId) + .then((page) => { + if (active) { + setConversations(page.conversations); + setConversationCursor(page.next_cursor ?? null); + } + }) + .catch(() => { if (active) setHistoryError(t("Conversation history could not be loaded. Start a new conversation or try again later.")); }); + return () => { active = false; }; }, [postId, accessToken]); + async function selectConversation(nextId: string) { + const requestId = ++conversationRequest.current; + setConversationLoading(true); + setHistoryError(null); + setAnswer(null); + try { + const conversation = await fetchPostChatConversation(accessToken, postId, nextId); + if (requestId !== conversationRequest.current) return; + setConversationId(conversation.conversation_id); + setExchanges(conversation.exchanges); + setConversationOlderCursor(conversation.older_cursor ?? null); + } catch { + if (requestId !== conversationRequest.current) return; + setHistoryError(t("Conversation history could not be loaded. Start a new conversation or try again later.")); + } finally { + if (requestId === conversationRequest.current) setConversationLoading(false); + } + } + + function startNewConversation() { + ++conversationRequest.current; + setConversationLoading(false); + setConversationId(null); + setConversationOlderCursor(null); + setExchanges(seededExchanges); + setQuestion(""); + setAnswer(null); + setError(null); + setHistoryError(null); + } + async function handleAsk(asked = question) { - if (!asked.trim()) return; + if (!asked.trim() || conversationLoading) return; + const requestId = ++askRequest.current; + const requestedConversationId = conversationId; setLoading(true); setError(null); try { - const result = await askPostChat(accessToken, postId, asked); + const result = await askPostChat(accessToken, postId, asked, requestedConversationId); + if (requestId !== askRequest.current) return; + const startsConversation = requestedConversationId === null + || (result.conversation_id !== undefined && result.conversation_id !== requestedConversationId); setAnswer(result); + if (result.conversation_id) { + setConversationId(result.conversation_id); + setConversations((current) => [ + { + conversation_id: result.conversation_id!, + title: current.find((row) => row.conversation_id === result.conversation_id)?.title ?? asked.trim().slice(0, 80), + updated_at: new Date().toISOString(), + turn_count: (current.find((row) => row.conversation_id === result.conversation_id)?.turn_count ?? 0) + 1, + }, + ...current.filter((row) => + row.conversation_id !== result.conversation_id + && row.conversation_id !== requestedConversationId), + ]); + } setExchanges((prev) => { const next: ChatExchange = { question_text: asked.trim(), @@ -299,15 +401,16 @@ function ChatPanel({ cited_post_ids: result.cited_post_ids, cited_posts: result.cited_posts, }; - return [...prev.filter((row) => row.question_text !== next.question_text), next]; + return startsConversation ? [next] : [...prev, next]; }); } catch (err) { + if (requestId !== askRequest.current) return; setError(orchestratorUnavailableMessage(err, "Chat")); if (err instanceof BackendError && err.status === 503) { setSeededOnly(true); } } finally { - setLoading(false); + if (requestId === askRequest.current) setLoading(false); } } @@ -316,19 +419,21 @@ function ChatPanel({ const firstCitedTitle = exchanges[0]?.cited_posts?.[0]?.post_title ?? (firstCitedPostId ? firstCitedPostId.slice(0, 8) : null); - const landedEvidencePostId = nameFirstAsk ? (evidencePostId ?? firstCitedPostId) : null; + const showNamedSeed = Boolean(nameFirstAsk && conversationId === null && exchanges[0]); + const landedEvidencePostId = showNamedSeed ? (evidencePostId ?? firstCitedPostId) : null; + const chatBusy = loading || conversationLoading; return (

{t("Ask about this lineage")}

- {nameFirstAsk && exchanges[0] ? ( + {showNamedSeed ? (

{firstAskNextAction(exchanges[0].question_text)}

) : null} - {nameFirstAsk && exchanges[0] ? ( + {showNamedSeed ? (

{exchanges[0].question_text}

{exchanges[0].answer_text}

@@ -342,12 +447,12 @@ function ChatPanel({ />
) : null} - {nameFirstAsk && firstCitedTitle ? ( + {showNamedSeed && firstCitedTitle ? (

{firstCitedNextAction(firstCitedTitle)}

) : null} - {nameFirstAsk && landedEvidencePostId ? ( + {showNamedSeed && landedEvidencePostId ? ( ) : null} - {nameFirstAsk && firstCitedTitle && landedEvidencePostId ? ( + {showNamedSeed && firstCitedTitle && landedEvidencePostId ? (

{landedEvidenceNextAction(firstCitedTitle)}

) : null} +
+ + {conversationCursor ? ( + + ) : null} + +
+ {conversationId && conversationOlderCursor ? ( + + ) : null} + {historyError ?

{historyError}

: null} {!seededOnly && (
setQuestion(event.target.value)} onKeyDown={(event) => event.key === "Enter" && handleAsk()} placeholder={t("What happened between these events?")} /> -
@@ -384,13 +547,13 @@ function ChatPanel({ )} {exchanges.length > 0 && (
- {exchanges.map((exchange) => ( + {exchanges.map((exchange, index) => ( ) : null} @@ -2673,13 +2833,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 analysisRunText("pendingLineage"); 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 analysisRunText("pendingMeasurement"); 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 analysisRunText("pendingTopicLineage"); 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 analysisRunText("pendingReport"); default: { const unexpected: never = run.run_kind_code; return unexpected; @@ -2688,22 +2848,36 @@ 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 connect the measurement service and re-run."; + return analysisRunText("failedMeasurement"); case "analysis_run_topic_lineage": - return "Open this run to see why it failed, then connect the TEPP transport and re-run."; + return analysisRunText("failedTopicLineage"); case "analysis_run_lineage": - return "Open this run to see why it failed, then retry reconstruction from a current snapshot."; + return analysisRunText("failedLineage"); case "analysis_run_report": - return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; + return analysisRunText("failedReport"); 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."; - case "analysis_status_succeeded": + return analysisRunText("running"); case "analysis_status_cancelled": + switch (run.run_kind_code) { + case "analysis_run_lineage": + return analysisRunText("cancelledLineage"); + case "analysis_run_tepp": + return analysisRunText("cancelledMeasurement"); + case "analysis_run_topic_lineage": + return analysisRunText("cancelledTopicLineage"); + case "analysis_run_report": + return analysisRunText("cancelledReport"); + default: { + const unexpected: never = run.run_kind_code; + return unexpected; + } + } + case "analysis_status_succeeded": case null: return null; default: { @@ -2719,25 +2893,13 @@ function analysisRunNextAction(run: AnalysisRun): string | null { function analysisRunEmptyPostsHint(run: AnalysisRun): 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 ask an administrator to capture a newer snapshot." - ); + return analysisRunText("emptyMeasurement"); case "analysis_run_topic_lineage": - return ( - "No posts were available at this cutoff for topic-lineage analysis. " + - "Open a later run, or ask an administrator to capture a newer snapshot." - ); + return analysisRunText("emptyTopicLineage"); case "analysis_run_lineage": - return ( - "No posts were available at this cutoff for reconstruction. " + - "Open a later run, or ask an administrator to capture a newer snapshot." - ); + return analysisRunText("emptyLineage"); case "analysis_run_report": - return ( - "No posts were available at this cutoff for the period report. " + - "Open a later run, or ask an administrator to capture a newer snapshot." - ); + return analysisRunText("emptyReport"); default: { const unexpected: never = run.run_kind_code; return unexpected; @@ -2754,28 +2916,28 @@ 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"; 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 analysisRunText( + isTopicLineage ? "corpusFailedTopicLineage" : "corpusFailedMeasurement", ); case "analysis_status_succeeded": - return `These posts are the cutoff corpus this ${service} run ${verbPast}.`; + return analysisRunText( + isTopicLineage ? "corpusSucceededTopicLineage" : "corpusSucceededMeasurement", + ); case "analysis_status_pending": case "analysis_status_running": - return `These posts are the cutoff corpus ${service} will ${verb} once this run finishes.`; + return analysisRunText( + isTopicLineage ? "corpusPendingTopicLineage" : "corpusPendingMeasurement", + ); case "analysis_status_cancelled": - return ( - `These posts are the cutoff corpus this ${service} run would have ${verbPast}. ` + - `The run was cancelled before ${result}.` + return analysisRunText( + isTopicLineage ? "corpusCancelledTopicLineage" : "corpusCancelledMeasurement", ); case null: - return `These posts are the cutoff corpus attached to this ${service} run.`; + return analysisRunText( + isTopicLineage ? "corpusAttachedTopicLineage" : "corpusAttachedMeasurement", + ); default: { const unexpected: never = run.status_code; return unexpected; @@ -2901,7 +3063,7 @@ function analysisRunCanStart(run: AnalysisRun): boolean { function analysisRunStartLabel(run: AnalysisRun): string { if (run.run_kind_code === "analysis_run_tepp") { - return "Start TEPP measurement"; + return "Start measurement"; } if (run.run_kind_code === "analysis_run_topic_lineage") { return "Start topic lineage"; @@ -2909,12 +3071,12 @@ function analysisRunStartLabel(run: AnalysisRun): string { return "Start reconstruction"; } -/** Failed TEPP/topic-lineage is terminal. Create cannot invent a Pending row. */ -function analysisRunCanRequestTeppRetry(run: AnalysisRun): boolean { +function analysisRunCanRetryMeasurement(run: AnalysisRun): boolean { return ( (run.run_kind_code === "analysis_run_tepp" || run.run_kind_code === "analysis_run_topic_lineage") && - run.status_code === "analysis_status_failed" + run.status_code === "analysis_status_failed" && + Boolean(run.scope_corporate_entity_id) ); } @@ -3013,7 +3175,8 @@ function AnalysisRunsPanel({ const [requesting, setRequesting] = useState(false); const [starting, setStarting] = useState(false); const [selectedEntityId, setSelectedEntityId] = useState(""); - const inFlightKeyRef = useRef(null); + const lineageRequestKeyRef = useRef(null); + const measurementRetryKeyRef = useRef(new Map()); const entitiesReady = corporateEntities !== null && entitiesLoadError === null; const requestLabel = requesting ? "Recording the run..." @@ -3049,10 +3212,10 @@ function AnalysisRunsPanel({ } setError(null); setRequesting(true); - if (inFlightKeyRef.current === null) { - inFlightKeyRef.current = crypto.randomUUID(); + if (lineageRequestKeyRef.current === null) { + lineageRequestKeyRef.current = crypto.randomUUID(); } - const idempotencyKey = inFlightKeyRef.current; + const idempotencyKey = lineageRequestKeyRef.current; try { const created = await createAnalysisRun(accessToken, { run_kind_code: "analysis_run_lineage", @@ -3062,12 +3225,12 @@ function AnalysisRunsPanel({ const listed = await fetchAnalysisRuns(accessToken); setRuns(listed.analysis_runs); setSelected(created); - inFlightKeyRef.current = null; + lineageRequestKeyRef.current = null; } catch (err) { if (err instanceof BackendError && err.status === 409) { - inFlightKeyRef.current = null; + lineageRequestKeyRef.current = null; setError( - "This request key already names a different reconstruction. Request again to start a new run.", + "This request key already names a different analysis. Request again to start a new run.", ); } else { setError(err instanceof BackendError ? err.message : String(err)); @@ -3093,6 +3256,37 @@ function AnalysisRunsPanel({ } } + async function handleRetryMeasurement() { + if (!selected || !analysisRunCanRetryMeasurement(selected)) return; + setError(null); + setStarting(true); + const sourceRunId = selected.analysis_run_id; + let idempotencyKey = measurementRetryKeyRef.current.get(sourceRunId); + if (!idempotencyKey) { + idempotencyKey = crypto.randomUUID(); + measurementRetryKeyRef.current.set(sourceRunId, idempotencyKey); + } + try { + const created = await createAnalysisRun(accessToken, { + run_kind_code: selected.run_kind_code, + corporate_entity_id: selected.scope_corporate_entity_id, + idempotency_key: idempotencyKey, + }); + const started = await startAnalysisRun(accessToken, created.analysis_run_id); + const listed = await fetchAnalysisRuns(accessToken); + setRuns(listed.analysis_runs); + setSelected(started); + measurementRetryKeyRef.current.delete(sourceRunId); + } catch (err) { + if (err instanceof BackendError && err.status === 409) { + measurementRetryKeyRef.current.delete(sourceRunId); + } + setError(err instanceof BackendError ? err.message : String(err)); + } finally { + setStarting(false); + } + } + async function handleOpen(runId: string) { setError(null); try { @@ -3153,7 +3347,7 @@ function AnalysisRunsPanel({ return (
  • )} - {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."} -

    + {analysisRunCanRetryMeasurement(selected) && ( + )} {analysisRunReportPeriod(selected) && onSelectReportPeriod && (