First block of text.
Second block of text.
diff --git a/AGENTS.md b/AGENTS.md
index e704c9058..936c7f1c3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -173,8 +173,12 @@ never a fabricated theta or a local psychometric substitute.
Buyer Board **Weekly VOC** is an ISO-8601 week list filter (ADR 0092).
Opening that filtered post focuses Event Lineage (ADR 0093). Do not
invent a week, a theta, or a cutoff body.
-Opening a Calendar commitment uses the same focus path (ADR 0094). Do not
+Opening a Calendar commitment uses the same focus path (ADR 0134). Do not
invent a week, a theta, a cutoff body, or a CalDAV event.
+Opening a Customer master related post uses the same focus path (ADR 0095).
+Do not invent a week, a theta, a cutoff body, a CalDAV event, or a customer.
+Opening an Ask Agent cited post uses the same focus path (ADR 0096). Do not
+invent a cited post.
## Tests
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 461ebacba..2c895860b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -69,6 +69,7 @@ flowchart LR
| `rankweave_client.py` | Fail-closed RankWeave ranking port (`weighted_reciprocal_rank_fuse` in-process; never invent a fused score or a theta) |
| `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread |
| `lineage_persistence.py` | Flattens reconstruct trees into `post_lineage_edge` row specs (parent, child, fused_score) |
+| `lineage_contract.py` | Versioned, store-agnostic provider boundary for bounded authorized evidence and opaque-reference results |
| `knowledge_graph.py` | Random-walk-with-restart relevance + per-node adaptive related-node cutoff (Tong et al., 2006) -- pure graph math, no Postgres |
| `keyman_extraction.py` | Pluggable LLM extraction of two-sided (our-side/counterparty) person mentions + N:N org affiliations from a post |
| `entity_relationship_classification.py` | Pluggable LLM classification of a named organization's relationship to the post author (`rel_voc`/`rel_vom`/`rel_vop`/`rel_vocc`/`rel_voco`/`rel_vos`) |
@@ -282,8 +283,10 @@ Keycloak issued; `src/App.tsx` renders a git-branch SVG of
`GET /api/lineage` (click a node to open that post; `post_admin` can
rebuild), the post list with a named Weekly VOC ISO-8601 week filter
(ADR 0092; opening that filtered post focuses Event Lineage, ADR 0093),
-Calendar commitments use the same Event Lineage focus path (ADR 0094), and
-the full detail popup includes Korean
+Calendar commitments use the same Event Lineage focus path (ADR 0134),
+Customer master related posts use the same Event Lineage focus path
+(ADR 0095). Ask Agent cited posts use the same Event Lineage focus path
+(ADR 0096), and the full detail popup includes Korean
summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel
(direct vs. indirect links; a link opens that post), the Keyman
affiliate tree (resolved ancestors plus unresolved org roots), Keyman +
diff --git a/CHANGELOG.d/2.12.7-korean-search-boundary.md b/CHANGELOG.d/2.12.7-korean-search-boundary.md
new file mode 100644
index 000000000..9ea0359d8
--- /dev/null
+++ b/CHANGELOG.d/2.12.7-korean-search-boundary.md
@@ -0,0 +1,7 @@
+# 2.12.7 — Preserve Korean search corroboration boundaries
+
+## Fixed
+
+- Accept a Korean organization token followed by a grammatical particle in a
+ natural Searxng snippet while still rejecting the token inside a longer
+ unrelated Hangul word.
diff --git a/CHANGELOG.d/2.12.8-ontology-org-shacl.md b/CHANGELOG.d/2.12.8-ontology-org-shacl.md
new file mode 100644
index 000000000..c8f5cb417
--- /dev/null
+++ b/CHANGELOG.d/2.12.8-ontology-org-shacl.md
@@ -0,0 +1,13 @@
+# 2.12.8 Organization ontology and SHACL boundaries
+
+The core Knowledge Graph ontology now distinguishes real organizations from
+the concepts used to classify them. `CorporateEntity` and `Team` reuse W3C ORG
+organization, organizational-unit, containment, and unit-membership semantics;
+SKOS remains responsible for Group, Company, and Plant level concepts and
+canonical/alternative labels.
+
+The ontology now publishes stable version/import metadata and a companion
+versioned SHACL graph. Regression tests cover the ORG/SKOS boundary, direct
+parent and team-owner cardinalities, and the continued relational lookup-code
+round trip. PostgreSQL remains authoritative and ontology imports are never
+network-dereferenced at runtime.
diff --git a/CHANGELOG.d/2.15.0-customer-hierarchy-boundary.md b/CHANGELOG.d/2.15.0-customer-hierarchy-boundary.md
new file mode 100644
index 000000000..9fcae3ac6
--- /dev/null
+++ b/CHANGELOG.d/2.15.0-customer-hierarchy-boundary.md
@@ -0,0 +1,7 @@
+# 2.15.0 — Keep unresolved customer parents non-authoritative
+
+## Fixed
+
+- A self-parent or cyclic customer relation now remains visibly unresolved in
+ the focus workspace instead of being presented as a valid parent. Review the
+ source hierarchy before using that relationship for navigation.
diff --git a/CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md b/CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md
new file mode 100644
index 000000000..99b6ba65d
--- /dev/null
+++ b/CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md
@@ -0,0 +1,11 @@
+# 2.15.0 Opening a Customer master related post focuses Event Lineage
+
+Customer master names authorized customer entities as current and to open a
+related post to read Event Lineage. That open focuses the popup Event Lineage
+heading. Home-list opens do not. No TEPP theta is invented.
+
+Customer Master now keeps one customer at the center of a responsive three-pane
+workspace: authorized hierarchy, visible parent/direct-child relationships, and
+source-backed linked evidence. Closing evidence no longer loses the selected
+customer context. Desktop, tablet, and phone layouts use the shared UI tokens
+and the 1024 px / 768 px responsive boundaries.
diff --git a/CHANGELOG.d/2.16.0-ask-agent-open-event-lineage.md b/CHANGELOG.d/2.16.0-ask-agent-open-event-lineage.md
new file mode 100644
index 000000000..d60ab9945
--- /dev/null
+++ b/CHANGELOG.d/2.16.0-ask-agent-open-event-lineage.md
@@ -0,0 +1,5 @@
+# 2.16.0 Opening an Ask Agent cited post focuses Event Lineage
+
+Ask Agent names authorized cited posts as current after an answer and to
+open one to read Event Lineage. That open focuses the popup Event Lineage
+heading. Home-list opens do not. No TEPP theta is invented.
diff --git a/CHANGELOG.d/2.16.0-pr262-hardening.md b/CHANGELOG.d/2.16.0-pr262-hardening.md
new file mode 100644
index 000000000..a8501f8ca
--- /dev/null
+++ b/CHANGELOG.d/2.16.0-pr262-hardening.md
@@ -0,0 +1,6 @@
+### Fixed
+
+- Keep embedded source images visible when a post also contains a Markdown
+ table, and remove the privileged self-modifying contract-repair workflow.
+- Align the Python package version and ontology ADR reference with the 2.16.0
+ release.
diff --git a/CHANGELOG.d/cross-repo-lineage-provider-contract.md b/CHANGELOG.d/cross-repo-lineage-provider-contract.md
new file mode 100644
index 000000000..812117b0e
--- /dev/null
+++ b/CHANGELOG.d/cross-repo-lineage-provider-contract.md
@@ -0,0 +1,14 @@
+# Unreleased: bounded cross-repository lineage provider contract
+
+LineageWeave now exposes a versioned, store-agnostic contract for authorized
+Naruon-shaped evidence. It preserves opaque evidence references, separate RFC
+email fields, project hints, cutoff exclusion, channel limitations, and a
+deterministic request digest without reading Naruon's database or claiming
+Naruon's authoritative project status. Nested email references and project
+hints have explicit request-level ceilings so callers cannot bypass the
+bounded-work contract with oversized collections.
+
+Provider failures now become an explicit unavailable-channel limitation and
+rerun with renormalized non-LLM weights. Long body text is retained in the
+request envelope without being flattened into the short pairwise comparison
+label.
diff --git a/CHANGELOG.d/customer-master-tree-projection.md b/CHANGELOG.d/customer-master-tree-projection.md
new file mode 100644
index 000000000..3d3f8991f
--- /dev/null
+++ b/CHANGELOG.d/customer-master-tree-projection.md
@@ -0,0 +1,14 @@
+### Fixed
+
+- Customer Master now preserves the authorized Group → Company → Plant hierarchy while promoting
+ missing-parent, self-parent, and cyclic relations to visible `unresolved` roots instead of silently
+ dropping those customers.
+- Customer hierarchy rendering now follows the ontology's W3C ORG containment and separate SKOS level
+ classification instead of conflating organization instances with taxonomy concepts.
+- Late related-post responses can no longer replace evidence for a newly selected customer entity.
+
+### Accessibility
+
+- Added a reusable WAI-ARIA customer tree with one roving focus target, branch expansion,
+ Arrow/Home/End navigation, Enter/Space evidence activation, exact level/position metadata, and an
+ independently owned source-post evidence panel.
diff --git a/CHANGELOG.d/external-lineage-contract.md b/CHANGELOG.d/external-lineage-contract.md
new file mode 100644
index 000000000..713cd12c5
--- /dev/null
+++ b/CHANGELOG.d/external-lineage-contract.md
@@ -0,0 +1,13 @@
+# External email/project lineage contract
+
+- Add a strict, versioned external analysis contract for future Naruon and separately governed consumer use.
+- Export immutable request/result types, strict parsing, canonical serialization, deterministic digests, stable errors, and the store-agnostic `analyze_external_lineage` package entry point.
+- Accept only bounded caller-authorized opaque evidence references; no provider credentials, mailbox access, persistence, provider mutation, or direct application-database integration is introduced.
+- Preserve caller-observed RFC/provider/manual parent relations separately from inferred reconstructed continuation.
+- Exclude caller-observed children from alternative inferred-parent scoring, optional model disclosure, and inferred-pair budget while retaining them as candidate history for later records.
+- Enforce available-time knowledge cutoffs and disclose excluded evidence without substituting later facts.
+- Reject explicit-parent cycles and candidate-pair work above the caller-approved limit before optional LLM/provider activity.
+- Expose exact active channel scores, weights, contributions, LLM availability state, proposed project groupings, and deterministic result digests.
+- Add JSON Schema Draft 2020-12, one canonical ADR 0133, APA 7th doctoring, and focused TDD coverage.
+- Publish `not_invoked` in the JSON Schema and ADR when an available optional
+ LLM has no candidate pair to judge.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a3559c0b9..5241e2f6d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,26 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.16.0] - 2026-08-19
+
+### Added
+
+- Opening an Ask Agent cited post now focuses Event Lineage and names Keyman
+ and evaluation as the next read. After an authorized answer, Ask Agent
+ names cited posts as current before that open. Home-list opens do not add
+ that focus or copy. No TEPP theta is invented. No cited post is invented
+(ADR 0096 / ADR 0039 / ADR 0016).
+
+## [2.15.0] - 2026-08-19
+
+### Added
+
+- Opening a Customer master related post now focuses Event Lineage and names
+ Keyman and evaluation as the next read. Customer master names authorized
+ customer entities as current before that open. Home-list opens do not add
+ that focus or copy. No TEPP theta is invented. No customer is invented
+(ADR 0095 / ADR 0037 / ADR 0016).
+
## [2.14.0] - 2026-08-19
### Added
@@ -12,7 +32,7 @@ All notable changes to this project are documented here. Format follows
and evaluation as the next read. Calendar names authorized commitments
as current before that open. Home-list opens do not add that focus or
copy. No TEPP theta is invented. No cutoff body is invented
- (ADR 0094 / ADR 0016).
+ (ADR 0134 / ADR 0016).
## [2.13.0] - 2026-08-19
@@ -29,7 +49,7 @@ All notable changes to this project are documented here. Format follows
- Board now names Weekly VOC as an ISO-8601 week list filter. The control
keeps Voice of Customer posts for the latest week present in the loaded
- list (UTC Thursday rule) and tells the buyer to open a post to read
+ list (UTC Thursday rule) and tells the reader to open a post to read
Event Lineage. Reset filters returns every VOC type and every week.
No TEPP theta is invented (ADR 0092).
@@ -38,30 +58,26 @@ All notable changes to this project are documented here. Format follows
### Changed
- Renamed "Buyer" terminology to reader/workspace naming across the frontend
- shell, backend evidence helpers, and living docs (ADR 0119). LineageWeave
- has no explicit buyer role, so `BuyerNav`/`BuyerDestination` became
- `WorkspaceNav`/`WorkspaceDestination`, `.buyer-gnb*` CSS became
- `.workspace-gnb*`, and prose referring to the reading user now says
- "reader" instead of "buyer". Historical ADRs and changelog entries keep
- their original wording as a point-in-time record.
+ shell, backend evidence helpers, and living docs (ADR 0119). Historical ADRs
+ and changelog entries retain their point-in-time wording.
### Fixed
+- Preserve source-order nested list units, numeric superscript footnotes,
+ HTML/OOXML table rows, and recognizable Markdown table rows across the
+ semantic-unit parser and reader body renderer. See the [product and
+ technical gap baseline](docs/product-technical-gap-baseline.md) and
+ [ADR 0103](docs/adr/0103-semantic-document-evidence-contract.md).
+- Preserve multiline VISION table rows, render parent and region OCR tables
+ accessibly, and request source-visible entity, relationship, layout, and
+ document-purpose evidence instead of a generic image caption. VISION calls
+ now share the structure channel's 600-second deep-agent runtime boundary;
+ an empty same-image retry can no longer erase previously observed OCR.
- Removed the completed one-shot Global Ask package-manager repair workflow;
normal product CI remains the only branch validation path.
- `make smoke` and `make seed` now run through the locked project `uv`
environment, so local OIDC and synthetic-data workflows resolve the same
pinned dependencies as CI.
-- The workspace Event Lineage global Search action now retries focus after the
- board finishes loading, so navigation from Customer master, Calendar, or
- Ask Agent lands the cursor in the search box. The handled request is consumed,
- so later board navigation does not steal focus, and Search closes an open
- mobile drawer like every other destination change.
-- Mobile Event Lineage evidence cards now read their translated column labels
- from the rendered cells instead of hardcoded English CSS, and the two drawer
- close controls have distinct accessible names.
-- Event Lineage SVG edges now retain their instance-specific direction markers,
- so parent-to-child arrows remain visible when multiple lineage groups render.
- All OpenAI-compatible chat-completion consumers now validate the shared
response envelope before parsing it, preventing malformed provider bodies
from escaping as raw `KeyError` or response-shape details.
diff --git a/CLAUDE.md b/CLAUDE.md
index 40e173ead..b71583083 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -78,10 +78,23 @@ ISO-8601 week stay; other VOC types and older weeks drop out. The Board
names Event Lineage as the next read (ADR 0092). Open a remaining post:
Event Lineage takes focus and names Keyman and evaluation next
(ADR 0093). A home-list open does not. Do not invent a theta.
-
## Calendar open (v2.14.0)
Open Calendar. Authorized commitments are current. Open a commitment:
Event Lineage takes focus and names Keyman and evaluation next
-(ADR 0094). A home-list open does not. Do not invent a theta or a
+(ADR 0134). A home-list open does not. Do not invent a theta or a
CalDAV event.
+
+## Customer master open (v2.15.0)
+
+Open Customer master. Authorized customer entities are current. Open a
+related post: Event Lineage takes focus and names Keyman and evaluation
+next (ADR 0095). A home-list open does not. Do not invent a theta or a
+customer.
+
+## Ask Agent open (v2.16.0)
+
+Open Ask Agent. After an authorized answer, cited posts are current. Open
+a cited post: Event Lineage takes focus and names Keyman and evaluation
+next (ADR 0096). A home-list open does not. Do not invent a theta or a
+cited post.
diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py
index 324c6cd67..180a660cf 100644
--- a/backend/app/analysis_run_start.py
+++ b/backend/app/analysis_run_start.py
@@ -11,7 +11,7 @@
import hashlib
import json
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from typing import Any
from uuid import UUID
@@ -21,15 +21,15 @@
AnalysisRunCreateError,
fetch_visible_analysis_run,
)
-from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from backend.app.analysis_run_outbox import (
latest_outbox_delivery_is_claimed,
latest_outbox_delivery_is_delivered,
outbox_request_digest,
)
from backend.app.lineage_ingestion import records_from_source_posts
+from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from lineageweave.adjudication_client import AdjudicationClient
-from lineageweave.http_client import HttpClientError, post_json
+from lineageweave.http_client import post_json
from lineageweave.lineage_persistence import lineage_edge_specs
from lineageweave.models import Edge
from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable
@@ -103,8 +103,8 @@ def transport(payload: dict[str, Any]) -> dict[str, Any]:
try:
headers = {"authorization": f"Bearer {api_key}"} if api_key.strip() else {}
return post_json(url, payload, headers=headers, timeout=30.0)
- except (HttpClientError, OSError, ValueError, TypeError) as exc:
- raise TeppNotAvailable("TEPP transport unavailable") from exc
+ except Exception as exc:
+ raise TeppNotAvailable("TEPP transport request failed") from exc
return TeppClient(transport=transport)
@@ -119,12 +119,12 @@ def tepp_run_request(
"""Build TEPP's published request from the frozen run, never a theta."""
cutoff = knowledge_cutoff
if cutoff.tzinfo is None:
- cutoff = cutoff.replace(tzinfo=timezone.utc)
+ cutoff = cutoff.replace(tzinfo=UTC)
return AnalysisRunRequest(
idempotency_key=idempotency_key,
tenant_workspace_id=str(corporate_entity_id),
snapshot_id=snapshot_sha256,
- knowledge_cutoff=cutoff.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ knowledge_cutoff=cutoff.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
model_contract_version=_TEPP_MODEL_CONTRACT,
output_profile=_TEPP_OUTPUT_PROFILE,
)
@@ -610,7 +610,7 @@ async def deliver_queued_analysis_run(
return await _visible_or_404(
conn, analysis_run_id, account_id, affiliated_entity_ids
)
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
try:
if not latest_outbox_delivery_is_claimed(latest):
await _append_outbox_delivery(
@@ -636,9 +636,8 @@ async def deliver_queued_analysis_run(
affiliated_entity_ids=affiliated_entity_ids,
adjudication_client=adjudication_client,
)
- finished = datetime.now(timezone.utc)
- if finished < now:
- finished = now
+ finished = datetime.now(UTC)
+ finished = max(finished, now)
await _append_outbox_delivery(
conn,
analysis_run_id,
@@ -699,7 +698,7 @@ async def _deliver_lineage_reconstruction(
adjudication_client: AdjudicationClient | None = None,
) -> None:
"""Persist ThreadWeave parent choices for the frozen bag."""
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
member_rows = await _snapshot_member_posts(
conn,
locked["analysis_source_snapshot_id"],
@@ -715,9 +714,8 @@ async def _deliver_lineage_reconstruction(
)
edges = lineage_edge_specs(records_from_source_posts(rows), llm=adjudication_client)
digest = reconstruction_result_digest(edges)
- finished = datetime.now(timezone.utc)
- if finished < now:
- finished = now
+ finished = datetime.now(UTC)
+ finished = max(finished, now)
await conn.execute(
"""
insert into analysis_run_reconstruction
@@ -759,7 +757,7 @@ async def _deliver_tepp_measurement(
tepp_client: TeppClient,
) -> None:
"""Submit the frozen snapshot through ``tepp_client``. Never persist a theta."""
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
request = tepp_run_request(
idempotency_key=str(locked["idempotency_key"]),
snapshot_sha256=str(locked["snapshot_sha256"]),
@@ -767,17 +765,15 @@ async def _deliver_tepp_measurement(
corporate_entity_id=str(locked["corporate_entity_id"]),
)
status_code, failure_code, envelope = _tepp_submission(tepp_client, request)
- if status_code == _SUCCEEDED and envelope is not None:
- if not await _persist_tepp_result(
- conn,
- analysis_run_id=analysis_run_id,
- envelope=envelope,
- ):
- status_code = _FAILED
- failure_code = "tepp_result_not_persisted"
- finished = datetime.now(timezone.utc)
- if finished < now:
- finished = now
+ if status_code == _SUCCEEDED and envelope is not None and not await _persist_tepp_result(
+ conn,
+ analysis_run_id=analysis_run_id,
+ envelope=envelope,
+ ):
+ status_code = _FAILED
+ failure_code = "tepp_result_not_persisted"
+ finished = datetime.now(UTC)
+ finished = max(finished, now)
await _append_status(
conn,
analysis_run_id,
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index e35c9a436..9ceef0ad2 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -14,9 +14,11 @@
def _pop_first_env(*names: str) -> str:
- """Read the first configured alias without leaving credentials in the environment."""
+ """Read the first alias, removing quotes preserved by Docker env files."""
for name in names:
value = os.environ.pop(name, "").strip()
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
+ value = value[1:-1]
if value:
return value
return ""
@@ -27,7 +29,7 @@ def main() -> None:
provider_key = _pop_first_env("LLM_GATEWAY_API_KEY", "LLM_API_KEY", "NVIDIA_NIM_API_KEY")
if not provider_key:
raise SystemExit("LLM_GATEWAY_API_KEY or LLM_API_KEY is required to start the real LLM service")
- auth_token = os.environ.get("CONTEXTUAL_ORCHESTRATOR_TOKEN", "").strip()
+ auth_token = _pop_first_env("CONTEXTUAL_ORCHESTRATOR_TOKEN")
if not auth_token:
raise SystemExit("CONTEXTUAL_ORCHESTRATOR_TOKEN is required to start the authenticated LLM service")
@@ -36,14 +38,14 @@ def main() -> None:
raise SystemExit("LLM_GATEWAY_API_URL or LLM_GATEWAY_URL is required to start the gateway")
if not provider_url.rstrip("/").endswith("/v1"):
provider_url = provider_url.rstrip("/") + "/v1"
- raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
+ raw_limit = _pop_first_env("LLM_GATEWAY_MAX_OUTPUT_TOKENS") or "4096"
try:
max_output_tokens = int(raw_limit)
except ValueError as exc:
raise SystemExit("LLM_GATEWAY_MAX_OUTPUT_TOKENS must be an integer") from exc
if not 64 <= max_output_tokens <= 4096:
raise SystemExit("LLM_GATEWAY_MAX_OUTPUT_TOKENS must be between 64 and 4096")
- raw_body_limit = os.environ.pop("CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES", str(8 * 1024 * 1024)).strip()
+ raw_body_limit = _pop_first_env("CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES") or str(8 * 1024 * 1024)
try:
max_body_bytes = int(raw_body_limit)
except ValueError as exc:
@@ -56,7 +58,7 @@ def main() -> None:
agent["base_url"] = provider_url
agent["credential_key"] = "LLM_GATEWAY_API_KEY"
agent.setdefault("provider_protocol", "auto")
- embedding_model = os.environ.get("LLM_GATEWAY_EMBEDDING_MODEL", "").strip()
+ embedding_model = _pop_first_env("LLM_GATEWAY_EMBEDDING_MODEL")
if embedding_model:
embedding_agents = [
agent
diff --git a/docs/adr/0005-relation-verification-agent.md b/docs/adr/0005-relation-verification-agent.md
index ed5bd8108..f9f6214c0 100644
--- a/docs/adr/0005-relation-verification-agent.md
+++ b/docs/adr/0005-relation-verification-agent.md
@@ -38,10 +38,14 @@ observed from LLM classification), not "this specific relationship
claim is definitely true" -- a genuinely false relationship between two
REAL organizations still returns results about each organization
separately, so this is an existence/plausibility check, not a full
-relationship-truth adjudicator. That is a real upgrade path once real
-usage shows the coarser signal under- or over-trusting results in
-practice, not implemented here because nothing yet demonstrates the
-need for it over this cheaper stage.
+relationship-truth adjudicator. A result is accepted only when every
+distinctive token in the proposed organization name occurs in the
+result's non-search host or content snippet; the result title is excluded
+because search engines echo the query there. This prevents one generic
+word in an unrelated result from validating an invented multi-token name.
+That is a real upgrade path once real usage shows the coarser signal under-
+or over-trusting results in practice, not implemented here because
+nothing yet demonstrates the need for full NLI over this cheaper stage.
The real implementation, `SearxngRelationVerificationClient`, queries a
**self-hosted** Searxng instance (`docker/searxng/`), never a
@@ -55,6 +59,12 @@ keeps the channel unavailable (never fabricates a verification result)
when `SEARXNG_BASE_URL` is unset, same discipline as every other
pluggable client in this repo.
+Evidence token selection excludes generic fixture descriptors such as
+`fictitious`, `nonexistent`, `placeholder`, `sample`, `example`, and `demo`.
+Those words can appear in unrelated search results and are not organization
+identity. This keeps synthetic demo data out of the corroboration signal while
+retaining distinctive organization tokens and cited URLs.
+
Persistence: `post_counterparty_entity` gains
`verification_status_code` (`common_lookup_value` category
`relation_verification_status`: `verify_pending` / `verify_corroborated`
diff --git a/docs/adr/0022-authorized-tepp-start.md b/docs/adr/0022-authorized-tepp-start.md
index 84fc71635..fd541a58a 100644
--- a/docs/adr/0022-authorized-tepp-start.md
+++ b/docs/adr/0022-authorized-tepp-start.md
@@ -37,6 +37,8 @@ authorized transaction:
4. submits through `TeppClient`. An empty `TEPP_TRANSPORT_URL` keeps the
default unavailable transport. A set URL POSTs the published wire
payload through the http(s)-only helper. File URLs stay unavailable;
+ `AnalysisRunRequest` rejects non-v1 versions and blank/non-text required
+ fields before the transport is called, matching TEPP's v1 JSON Schema;
5. appends Failed / `tepp_not_available` when the transport is missing
or refused, or Failed / `tepp_result_not_persisted` when TEPP accepts
an envelope this product cannot store yet.
@@ -93,3 +95,7 @@ World Wide Web Consortium. https://www.w3.org/TR/prov-dm/
World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C
Recommendation). https://www.w3.org/TR/prov-o/
+
+ContextualWisdomLab. (2026). *TEPP API and modular integration contract*
+([Computer software documentation]). GitHub.
+https://github.com/ContextualWisdomLab/TEPP/blob/main/docs/API_CONTRACT.md
diff --git a/docs/adr/0095-internal-relation-evidence.md b/docs/adr/0094-internal-relation-evidence.md
similarity index 96%
rename from docs/adr/0095-internal-relation-evidence.md
rename to docs/adr/0094-internal-relation-evidence.md
index c5e44e4b3..31d8e8f22 100644
--- a/docs/adr/0095-internal-relation-evidence.md
+++ b/docs/adr/0094-internal-relation-evidence.md
@@ -1,4 +1,4 @@
-# ADR 0095: Preserve authorized internal evidence for relation verification
+# ADR 0094: Preserve authorized internal evidence for relation verification
- Status: Accepted
- Date: 2026-08-18
diff --git a/docs/adr/0095-customer-master-open-focuses-event-lineage.md b/docs/adr/0095-customer-master-open-focuses-event-lineage.md
new file mode 100644
index 000000000..c782cc6dc
--- /dev/null
+++ b/docs/adr/0095-customer-master-open-focuses-event-lineage.md
@@ -0,0 +1,35 @@
+# ADR 0095: Opening a Customer master related post focuses Event Lineage
+
+- Status: Accepted
+- Date: 2026-08-19
+
+## Context
+
+Board Weekly VOC and Calendar commitment opens already focus Event Lineage
+(ADR 0093 / ADR 0134). Customer master is the remaining buyer GNB destination
+that opens an authorized related post. That open was a home-list open: the
+popup body appeared and Event Lineage did not take focus.
+
+## Decision
+
+Opening a related post on Customer master is a `fromCustomerMaster` open.
+That open reuses the Event Lineage focus path used by report-member,
+Weekly VOC, and Calendar opens:
+
+- Customer master names the next action: authorized customer entities are
+ current; open a related post to read Event Lineage.
+- The popup Event Lineage heading takes focus.
+- The popup names the opened post as current in Event Lineage and tells
+ the buyer to read Keyman and evaluation next.
+
+A Board home-list open does not focus Event Lineage and does not add that
+copy. A `?post=` deep link is still a home-list open.
+
+No TEPP theta is invented. No cutoff body is invented (ADR 0016). Customer
+master does not invent a customer or a parent (ADR 0037 / ADR 0010).
+
+## Consequences
+
+- Customer master, Calendar, Weekly VOC, and report-member opens share one
+ focus contract.
+- Closing the popup clears the Customer master open flag.
diff --git a/docs/adr/0096-ask-agent-open-focuses-event-lineage.md b/docs/adr/0096-ask-agent-open-focuses-event-lineage.md
new file mode 100644
index 000000000..a16adb8f9
--- /dev/null
+++ b/docs/adr/0096-ask-agent-open-focuses-event-lineage.md
@@ -0,0 +1,36 @@
+# ADR 0096: Opening an Ask Agent cited post focuses Event Lineage
+
+- Status: Accepted
+- Date: 2026-08-19
+
+## Context
+
+Board Weekly VOC, Calendar, and Customer master opens already focus Event
+Lineage (ADR 0093 / ADR 0134 / ADR 0095). Ask Agent is the remaining buyer
+GNB destination that opens an authorized cited post. That open was a
+home-list open: the popup body appeared and Event Lineage did not take
+focus.
+
+## Decision
+
+Opening a cited post on Ask Agent is a `fromAskAgent` open. That open
+reuses the Event Lineage focus path used by report-member, Weekly VOC,
+Calendar, and Customer master opens:
+
+- After an authorized answer, Ask Agent names the next action: cited posts
+ are current; open a cited post to read Event Lineage.
+- The popup Event Lineage heading takes focus.
+- The popup names the opened post as current in Event Lineage and tells
+ the buyer to read Keyman and evaluation next.
+
+A Board home-list open does not focus Event Lineage and does not add that
+copy. A `?post=` deep link is still a home-list open.
+
+No TEPP theta is invented. No cutoff body is invented (ADR 0016). Ask Agent
+does not invent a cited post (ADR 0039).
+
+## Consequences
+
+- Ask Agent, Customer master, Calendar, Weekly VOC, and report-member
+ opens share one focus contract.
+- Closing the popup clears the Ask Agent open flag.
diff --git a/docs/adr/0103-semantic-document-evidence-contract.md b/docs/adr/0103-semantic-document-evidence-contract.md
new file mode 100644
index 000000000..88cbcd6a9
--- /dev/null
+++ b/docs/adr/0103-semantic-document-evidence-contract.md
@@ -0,0 +1,82 @@
+# ADR 0103 — Preserve semantic document evidence across source and buyer views
+
+**Decision status:** Proposed on the stacked product-gap branch
+**Date:** 2026-08-20
+**Figma File ID:** `1Su3lDRmiZdcUs47t1QwIX`
+**Related baseline:** [Product and Technical Gap Baseline](../product-technical-gap-baseline.md)
+
+## Context
+
+Live aggregate inspection identified recurring buyer-visible loss at the
+source boundary: superscript footnotes, nested list order/depth, table rows,
+and Markdown tables were not represented consistently between Python
+ingestion, PostgreSQL units, and the React popup. A flattened string cannot
+reconstruct a table row, a branch in a list, or the position of an image. It
+also makes a later LLM summary less auditable.
+
+The HTML Living Standard defines the semantic elements used by the source
+boundary, including `ol`, `li`, `table`, `tr`, and `sup`. CommonMark provides a
+versioned baseline for Markdown block parsing; table syntax remains an
+extension in many Markdown dialects, so the implementation accepts only a
+recognizable header/separator/data shape and otherwise preserves plain text.
+
+## Decision
+
+1. Parse source content into ordered semantic units before embedding or
+ summarization. A unit retains its source label, source order, indentation
+ metadata, and image position.
+2. Group HTML and OOXML table cells into row units. Markdown tables are
+ recognized only when a header row is immediately followed by a separator
+ row; the separator itself is not evidence content.
+3. Treat explicit CSS/OOXML indentation as authoritative. List-container
+ nesting contributes structural depth but must not double-count an explicit
+ source width.
+4. Mark a numeric footnote only when the source uses a numeric `sup` marker;
+ a numeric table cell or ordinary numbered text is not a footnote by itself.
+5. Keep the frontend's raw-source fallback aligned with the persisted unit
+ labels. Persisted row units render as accessible tables; unresolved
+ structure remains visibly unresolved and actionable.
+6. Apply the same narrow Markdown-table renderer to persisted image OCR.
+ VISION output may use multiple `TEXT` lines so row boundaries survive; its
+ caption names only visible entities, relationships, layout, and document
+ purpose rather than offering a generic one-sentence description. The
+ client allows 600 seconds for deep orchestrator work; a 180-second local
+ cutoff already terminated a valid live response before delivery.
+7. Serialize replacement per source post and reject a same-image retry when
+ its content hash matches non-empty persisted OCR but the retry returns no
+ OCR. Provider completion is transport evidence, not permission to erase a
+ stronger prior observation. During an operator backfill, this typed
+ preservation failure skips only the affected post, records it in the
+ aggregate result, and allows the remaining selected posts to continue.
+
+## Rejected alternatives
+
+- Flattening all bodies into one embedding string: loses row, list, and image
+ boundaries and cannot be repaired at display time.
+- Treating every leading number as a footnote: mislabels table rows and
+ numbered instructions.
+- Calling a provider directly from the parser: violates the orchestrator
+ trust boundary and makes evidence/cost lineage incomplete.
+- Creating a separate parsing service: the existing shared chunker and
+ persistence boundary are sufficient; Ponytail favors the smaller change.
+
+## Consequences
+
+- Search and summaries receive smaller, meaningful units without exposing raw
+ markup or image base64.
+- The database keeps the existing normalized unit tables; this decision adds
+ no denormalized JSON field or new service.
+- A weaker same-image VISION retry fails before replacement, leaving the
+ prior committed evidence available for a later orchestrator retry.
+- A protected retry does not abort an entire operator batch; the skipped-post
+ count is visible to the operator without exposing raw post content.
+- Markdown dialects outside the narrow recognized shape remain plain text and
+ are reported as a future parser extension rather than guessed.
+
+## Verification
+
+The baseline's synthetic tests cover numeric superscript footnotes, marker
+footnotes, nested `ol`/`ul`/`oi` order and depth, HTML/OOXML rows, Markdown
+rows, React table rendering, and unresolved indentation. Full CI remains the
+release gate. A persistence regression test proves that an empty same-hash
+VISION retry cannot delete previously observed OCR.
diff --git a/docs/adr/0105-mathematical-script-semantic-normalization.md b/docs/adr/0105-mathematical-script-semantic-normalization.md
deleted file mode 100644
index 839c3c98c..000000000
--- a/docs/adr/0105-mathematical-script-semantic-normalization.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# ADR 0105: Preserve explicit metric scripts in semantic text
-
-- Status: Accepted
-- Date: 2026-08-21
-
-## Context
-
-Source posts may encode a metric unit such as `m3` or an indexed
-quantity such as `m3`. Removing the script element loses searchable
-and buyer-visible mathematical meaning, while treating every numeric
-superscript as mathematics would break the existing footnote contract.
-
-## Decision
-
-1. Preserve the original source body unchanged.
-2. In derived semantic text, normalize only an explicit bounded metric base
- (`m`, `cm`, `mm`, `km`, or `kg`, optionally preceded by a number) followed
- by one-to-three numeric `sup` or `sub` elements into Unicode
- superscript/subscript digits. For example, `5m3` becomes `5m³`.
-3. Leave ordinary numeric superscripts on prose under the existing footnote
- role contract.
-4. Apply the same normalization in backend chunks and frontend rendering.
-
-## Consequences
-
-Metric exponents remain searchable and readable without inventing formula
-semantics. Arbitrary mathematical markup beyond this bounded case remains an
-explicit open gap and must be covered by a later ADR and fixture before being
-normalized.
diff --git a/docs/adr/0103-source-whitespace-is-not-authoritative-structure.md b/docs/adr/0105-source-whitespace-is-not-authoritative-structure.md
similarity index 96%
rename from docs/adr/0103-source-whitespace-is-not-authoritative-structure.md
rename to docs/adr/0105-source-whitespace-is-not-authoritative-structure.md
index 4dc6c7ed5..1ced86378 100644
--- a/docs/adr/0103-source-whitespace-is-not-authoritative-structure.md
+++ b/docs/adr/0105-source-whitespace-is-not-authoritative-structure.md
@@ -1,4 +1,4 @@
-# ADR 0103: Source-only whitespace is not authoritative structure
+# ADR 0105: Source-only whitespace is not authoritative structure
- Status: Accepted
- Date: 2026-08-20
diff --git a/docs/adr/0119-lineage-provider-contract.md b/docs/adr/0119-lineage-provider-contract.md
new file mode 100644
index 000000000..cfc02a0a4
--- /dev/null
+++ b/docs/adr/0119-lineage-provider-contract.md
@@ -0,0 +1,73 @@
+# ADR 0119: Publish a bounded LineageWeave provider contract
+
+- Status: Accepted on this PR; not main-branch truth until merged
+- Date: 2026-08-21
+- Decision owners: LineageWeave maintainers
+
+## Context
+
+Naruon owns mailbox/provider access, canonical source identities, workspace
+authorization, and provider mutations. LineageWeave owns reconstruction,
+channel evidence, cutoff semantics, and inferred lineage. Sharing application
+tables or copying LineageWeave internals into Naruon would create a second
+authority and bypass the existing ABAC boundary.
+
+## Decision
+
+`lineageweave.lineage_contract` is the provider-side versioned boundary for a
+bounded, store-agnostic analysis request. It uses immutable Python dataclasses
+and canonical JSON (`lineage-analysis/v1`) so a future HTTP or generated SDK
+adapter can be added without changing reconstruction.
+
+- Requests carry an opaque caller-owned `evidence_ref`, an opaque
+ `authorization_scope_ref`, separate occurred/available clocks, bounded text,
+ bounded email reference collections, and bounded non-authoritative project
+ hints.
+- A request is valid only when evidence references are unique, clocks are
+ timezone-aware, payload budgets are bounded, and hints point to submitted
+ evidence.
+- `knowledge_cutoff` excludes evidence whose `available_at` is later. Excluded
+ evidence is reported as a limitation and cannot appear in any edge.
+- The implementation reuses the existing `reconstruct()` pipeline and returns edges only in
+ terms of submitted opaque evidence references, with channel scores and
+ explicit `inferred` truth status.
+- Missing contextual-orchestrator adjudication is an explicit limitation; it
+ is not a zero score or a fabricated negative signal.
+- Provider exceptions and non-finite or out-of-range provider scores drop the
+ LLM channel and rerun the bounded reconstruction with renormalized channel
+ weights. Raw provider response bodies and exception text never cross the
+ public contract.
+- The reconstruction text channel receives the short evidence label only;
+ bounded body text remains request evidence and is not flattened into every
+ pairwise comparison.
+- The request digest is the idempotency identity. Persistence, retry, tenant
+ authorization, and provider actions remain the consumer's responsibility.
+
+LineageWeave does not read Naruon's database, receive Naruon credentials, or
+claim authoritative project/task/calendar status. Naruon may adopt a proposed
+projection only through its own policy.
+
+## Consequences
+
+The provider can be tested with synthetic Naruon-shaped evidence and shipped
+independently. A service adapter can later expose the same JSON without
+leaking database identifiers. The first version intentionally does not create
+project projections or provider mutations; those require a separate reviewed
+contract and evidence policy.
+
+## Research and standards grounding
+
+- World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*.
+- Internet Engineering Task Force. (2008). *RFC 5322: Internet message format*.
+- World Wide Web Consortium. (2017). *OWL-Time ontology*.
+- National Institute of Standards and Technology. (2020). *Security and
+ privacy controls for information systems and organizations: NIST SP
+ 800-53 Rev. 5*. https://doi.org/10.6028/NIST.SP.800-53r5
+- OWASP Foundation. (2024). *Application Security Verification Standard
+ 5.0.0*. https://github.com/OWASP/ASVS
+- MITRE. (n.d.). *CWE-209: Generation of error message containing sensitive
+ information*. https://cwe.mitre.org/data/definitions/209.html
+
+These standards support explicit provenance, separate message evidence,
+distinct event/availability clocks, and prevention of sensitive information in
+error messages; they do not authorize a cross-service database dependency.
diff --git a/docs/adr/0124-customer-master-tree-projection.md b/docs/adr/0124-customer-master-tree-projection.md
new file mode 100644
index 000000000..1ea83cc20
--- /dev/null
+++ b/docs/adr/0124-customer-master-tree-projection.md
@@ -0,0 +1,114 @@
+# ADR 0124: Cycle-safe customer master tree projection
+
+- **Status:** Accepted
+- **Date:** 2026-08-21
+- **Owners:** Buyer surface and ontology projection
+- **Figma file ID:** `SBpgot7uTvMxEaxUwvoc0S`
+
+## Context
+
+`GET /api/customer-master` returns the authorized corporate-entity projection as a flat array with
+`parent_entity_id`. The buyer surface previously rebuilt a nested list directly in `App.tsx`.
+That recovered ordinary Group → Company → Plant relationships, but it had three product defects:
+
+1. a self-parent or multi-node cycle had no root and therefore disappeared from the customer master;
+2. `aria-expanded` described whether related posts were open, not whether the hierarchy branch was
+ expanded; and
+3. the nested list did not implement the keyboard and focus behavior required by the WAI-ARIA tree
+ pattern.
+
+The ontology now correctly separates real organization instances from their classification levels:
+
+- `CorporateEntity` specializes `org:Organization`;
+- `subOrganizationOf` is the semantic projection of `corporate_entity.parent_entity_id` and
+ specializes `org:subOrganizationOf`;
+- `hasSubOrganization` is its inverse;
+- Group, Company, and Plant are `CorporateEntityLevel` SKOS concepts selected by `hasEntityLevel`;
+- the SHACL profile requires exactly one level and no more than one parent in this projection.
+
+An entity authorized for the caller must not disappear because an imported hierarchy edge is malformed
+or because its parent is outside the caller's visible scope. At the same time, the browser must not
+invent a replacement parent or promote an inferred relation to an authoritative ontology fact.
+
+The W3C Organization Ontology supplies organizational containment. SKOS supplies the separate level
+classification. SHACL supplies closed-world cardinality checks. The WAI-ARIA Authoring Practices Tree
+View Pattern defines `tree`, `treeitem`, `group`, branch `aria-expanded`, roving focus, and arrow-key
+navigation for an interactive hierarchy.
+
+## Decision
+
+1. Keep PostgreSQL `corporate_entity.parent_entity_id` as the authority. The ontology and SHACL graphs
+ are semantic and validation projections, not a second writable hierarchy.
+2. Preserve the current ontology boundary: organization containment uses W3C ORG; Group/Company/Plant
+ level classification uses SKOS. Do not use `skos:broader` between real company instances.
+3. Move browser hierarchy assembly into `frontend/src/customerMasterTree.ts`.
+4. Preserve API order for roots and siblings and preserve every unique authorized entity.
+5. Keep a parent link only when the parent is present in the authorized response.
+6. Promote a missing-parent, self-parent, or every member of a detected cycle to a visible root and
+ mark the relation `unresolved`; do not infer a replacement parent.
+7. Render the projection through the reusable `CustomerMasterTree` component.
+8. Separate hierarchy disclosure from evidence disclosure:
+ - Left/Right Arrow collapses, expands, and moves to parent or first child.
+ - Up/Down Arrow, Home, and End move through visible tree items.
+ - Enter or Space selects an entity and opens source-backed related posts.
+ - Related-post evidence is rendered outside the `tree` ownership boundary.
+9. Explicitly declare `aria-level`, `aria-posinset`, and `aria-setsize`; use one roving `tabIndex=0`
+ and `aria-selected` for the entity whose evidence is open.
+10. Reject stale related-post responses after the buyer selects another entity.
+11. Keep the current flat API as a bounded authorized projection. Legal ownership, operating
+ structure, sales roll-up, billing structure, and historical hierarchy remain a later normalized,
+ effective-dated relation model.
+
+## Consequences
+
+### Positive
+
+- Ordinary Group → Company → Plant structures remain visibly hierarchical.
+- Real organization instances and SKOS classification concepts remain semantically distinct.
+- Malformed or partially visible relations are reviewable instead of silently omitted.
+- Keyboard and screen-reader users receive a real tree interaction model.
+- Related-post evidence cannot introduce non-tree roles inside the tree ownership boundary.
+- The component is independently testable and represented in Storybook.
+- Related-post evidence remains source-backed, lazy-loaded, and stale-response safe.
+
+### Trade-offs
+
+- An unresolved relation is shown at the root level, which is intentionally less specific than
+ guessing a parent.
+- Client-side defensive projection does not repair the authoritative data. Operators still need a
+ data-quality workflow for cyclic or invalid source relations.
+- The current SHACL profile constrains cardinality and class, but it does not yet prove global
+ acyclicity or allowed level transitions.
+- The referenced Figma file does not contain a dedicated public customer-tree frame. This change uses
+ the existing design-token and Storybook boundary rather than claiming pixel equivalence to a
+ nonexistent frame.
+
+## Verification
+
+- Existing ontology interoperability tests require ORG organization containment, separate SKOS level
+ concepts, stable imports/versioning, and SHACL parent/level cardinalities.
+- Pure frontend tests cover a three-level hierarchy, missing parent, self-parent, multi-node cycle,
+ descendant preservation, ordering, and collapsed navigation order.
+- Component tests cover ARIA metadata, roving focus, Arrow/Home/End navigation, branch disclosure,
+ Enter/Space activation, evidence outside the tree, stale request rejection, request failure,
+ related-post opening, and unresolved relations.
+- Storybook includes ordinary and malformed-relation states.
+- Focused ontology/frontend tests, frontend lint, complete Vitest suite, production build, and
+ Storybook build must pass on the exact PR head.
+
+## References — APA 7th
+
+World Wide Web Consortium. (2009). *SKOS simple knowledge organization system reference*.
+https://www.w3.org/TR/skos-reference/
+
+World Wide Web Consortium. (2014). *The Organization Ontology*.
+https://www.w3.org/TR/vocab-org/
+
+World Wide Web Consortium. (2017). *Shapes Constraint Language (SHACL)*.
+https://www.w3.org/TR/shacl/
+
+World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2*.
+https://www.w3.org/TR/WCAG22/
+
+World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *Tree view pattern*.
+https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
diff --git a/docs/adr/0125-customer-master-three-pane-workspace.md b/docs/adr/0125-customer-master-three-pane-workspace.md
new file mode 100644
index 000000000..b6ae71a74
--- /dev/null
+++ b/docs/adr/0125-customer-master-three-pane-workspace.md
@@ -0,0 +1,125 @@
+# ADR 0125: Customer-centered three-pane Customer Master workspace
+
+- **Status:** Accepted
+- **Date:** 2026-08-21
+- **Owners:** Customer Master product surface and evidence navigation
+- **Figma file ID:** `SBpgot7uTvMxEaxUwvoc0S`
+- **Figma desktop frame:** `313:2`
+- **Figma mobile frame:** `314:2`
+
+## Context
+
+ADR 0124 established a cycle-safe, authorized WAI-ARIA tree for Group → Company → Plant
+containment and deliberately kept related-post evidence outside the tree ownership boundary. That
+corrected malformed hierarchy handling and keyboard navigation, but the product composition remained
+vertically fragmented:
+
+1. the hierarchy occupied the first block;
+2. selecting an entity caused evidence to appear below the complete tree;
+3. relationship-network, unresolved-hint, source-author, and Keyman blocks continued further down;
+4. the currently selected customer was not held as the stable visual center of the task.
+
+Users therefore had to remember which entity they selected while scanning a long page. Parent/child
+relationships and source evidence were available, but they were not arranged around the customer that
+the user was trying to understand. A free-form graph would add visual complexity and would also risk
+presenting inferred edges as if they were authoritative Customer Master facts.
+
+The uploaded *웹 시스템 UI·UX 표준 가이드 Ver.3.0* requires clear navigation hierarchy and active
+state, a 1024 px PC boundary, a 768 px phone boundary, responsive content ordering, system-font
+control, and content-page actions that remain discoverable on small screens. ADR 0118 adopted those
+breakpoints and design-token rules for LineageWeave.
+
+## Decision
+
+1. Compose Customer Master as one customer-centered workspace with three explicit semantic panes:
+ - **01 Customer hierarchy:** the existing authorized, cycle-safe WAI-ARIA tree;
+ - **02 Selected customer:** one stable customer summary with the visible parent and direct child
+ relationships around that customer;
+ - **03 Linked evidence:** only source-backed related posts, with the existing open-post handoff to
+ Event Lineage.
+2. Keep the selected customer separate from whether its evidence pane is open. Closing evidence must
+ not lose the customer's centered relationship context.
+3. Keep `corporate_entity.parent_entity_id` authoritative. The middle pane may recenter on a visible
+ parent or direct child, but it must not infer hidden parents, siblings, ownership, or alternative
+ organizational edges.
+4. Preserve all ADR 0124 hierarchy semantics and keyboard behavior. Branch disclosure remains
+ independent from customer selection.
+5. Keep source-backed related posts outside `role="tree"`. A tree item may reference the evidence
+ region with `aria-controls` only while that region exists.
+6. Preserve stale-request rejection and per-entity evidence caching when users move rapidly between
+ customers.
+7. Use existing design tokens for border, focus, color, status, spacing, and dark-mode behavior. Do
+ not introduce a second Customer Master palette.
+8. Use the UI·UX guide's three responsive tiers:
+ - **PC, greater than 1024 px:** all three panes in one horizontal row;
+ - **Tablet, up to 1024 px:** hierarchy and selected customer side by side, evidence full width;
+ - **Phone, up to 768 px:** hierarchy → selected customer → evidence as one vertical task sequence.
+9. Maintain complete product copy for all five supported locales: English, Korean, Chinese,
+ Japanese, and Vietnamese.
+10. Represent the desktop, phone, malformed-relation, and unselected states in Storybook. The Figma
+ frames are the visual design evidence; Storybook remains the executable state inventory.
+
+## Alternatives considered
+
+### Keep the vertical tree and accordion evidence
+
+Rejected because it preserves the long-memory task: the selected customer scrolls away while evidence
+and other relationship blocks appear below.
+
+### Replace the tree with a network graph
+
+Rejected because graph layout does not provide a predictable hierarchy scan, is harder to operate with
+a keyboard, and can blur the boundary between authoritative containment and inferred relationships.
+Graphs remain appropriate for Event Lineage, not for the Customer Master authority projection.
+
+### Put all relationships in one wide table
+
+Rejected because a table flattens the Group → Company → Plant path and makes recentering around one
+customer less direct. Exact-value tables may supplement a graph, but they do not replace the
+hierarchical navigation contract here.
+
+## Consequences
+
+### Positive
+
+- The selected customer remains visually and semantically central while users inspect its parent,
+ children, and evidence.
+- The page expresses a stable left-to-right task: choose → understand relations → verify evidence.
+- Evidence can close without losing the selected customer or its relationship context.
+- Existing WAI-ARIA tree behavior, malformed-relation visibility, authorization scope, and
+ source-backed evidence boundaries remain intact.
+- Responsive layouts preserve the same semantic order instead of hiding relationship context behind a
+ separate phone-only interaction model.
+- Figma and Storybook now describe the same product surface and edge states.
+
+### Trade-offs
+
+- A three-pane desktop layout uses more horizontal space than the previous vertical list.
+- Tablet users receive a two-row composition rather than all three panes in one row.
+- Only the authoritative parent and direct children are shown in the middle pane. Siblings, historical
+ roles, billing structure, and inferred relationships require separately typed products or later
+ effective-dated relation models.
+
+## Verification
+
+- Existing `CustomerMasterTree` tests continue to cover WAI-ARIA metadata, roving focus,
+ Arrow/Home/End navigation, independent branch disclosure, stale request rejection, request failure,
+ evidence caching, and malformed hierarchy members.
+- New workspace tests cover the three-pane composition, stable selected-customer state, parent and
+ direct-child recentering, source evidence outside the tree, evidence close/reopen behavior,
+ unresolved relation explanation, leaf boundary copy, and five-locale copy completeness.
+- Frontend lint, TypeScript, complete Vitest, production build, and Storybook build must pass on the
+ exact PR head.
+- GitHub protected checks and independent review remain the final merge authority.
+
+## References — APA 7th
+
+ContextualWisdomLab. (2026). *ADR 0118: UI·UX Standard Guide Ver.3.0 design overhaul*.
+
+ContextualWisdomLab. (2026). *ADR 0124: Cycle-safe customer master tree projection*.
+
+World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2*.
+https://www.w3.org/TR/WCAG22/
+
+World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *Tree view pattern*.
+https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
diff --git a/docs/adr/0130-mathematical-script-semantic-normalization.md b/docs/adr/0130-mathematical-script-semantic-normalization.md
new file mode 100644
index 000000000..92daf84c5
--- /dev/null
+++ b/docs/adr/0130-mathematical-script-semantic-normalization.md
@@ -0,0 +1,50 @@
+# ADR 0130: Preserve explicit metric scripts in semantic text
+
+**Status:** Accepted on this PR; not protected-main truth
+**Date:** 2026-08-21
+**Owners:** LineageWeave ingestion and buyer-surface maintainers
+
+## Context
+
+Source posts commonly encode a unit such as `m3`, `m3`,
+`m^3`, or `m_3` with HTML or plain-text notation. Dropping the markup changes
+the searchable meaning to `m3`, while treating every numeric `sup` element as
+mathematics would break the existing numeric-footnote contract. Full MathML
+parsing is not yet justified by the current product surface, but the loss of
+explicit unit scripts is a buyer-visible defect.
+
+MathML 4 defines `msup`, `msub`, and `msubsup` as structural script elements;
+HTML `sup`/`sub` are a permitted lighter-weight notation when detailed
+mathematical markup is not required. This decision therefore adds a bounded
+normalization boundary and keeps the source representation unchanged.
+
+## Decision
+
+1. Preserve the immutable source body exactly as imported.
+2. In derived semantic text only, normalize an explicitly bounded metric base
+ (`m`, `cm`, `mm`, `km`, or `kg`, optionally preceded by a number) followed
+ by numeric `sup`/`sub` markup or plain-text `^`/`_` notation into Unicode
+ superscript/subscript digits. For example, `5m3` and `5m^3`
+ become `5m³`, while `m3` and `m_3` become `m₃`.
+3. Keep ordinary numeric superscripts and caret expressions on prose under the existing footnote
+ role contract. Do not infer a mathematical formula from an arbitrary word.
+4. Apply the same bounded normalization in backend semantic chunks and the
+ React buyer display so search text and visible text agree.
+5. Defer full MathML/LaTeX parsing, expression trees, and ontology term
+ creation until an authorized fixture demonstrates a need beyond metric
+ scripts. Any such change requires a new ADR and parser contract.
+
+## Consequences
+
+- Search and the buyer popup retain the visible distinction between `m³` and
+ `m3` without exposing source HTML to the embedding model.
+- Existing numeric-footnote tests remain unchanged because the bounded metric
+ pattern is the only new conversion.
+- The current implementation does not claim to understand arbitrary equations;
+ unsupported script markup remains ordinary source text and must not be
+ presented as a parsed ontology expression.
+
+## References (APA 7th)
+
+World Wide Web Consortium. (2026). *Mathematical Markup Language (MathML)
+Version 4.0* (W3C Recommendation). https://www.w3.org/TR/mathml4/
diff --git a/docs/adr/0119-retire-buyer-terminology.md b/docs/adr/0131-retire-buyer-terminology.md
similarity index 98%
rename from docs/adr/0119-retire-buyer-terminology.md
rename to docs/adr/0131-retire-buyer-terminology.md
index 4124e1e0a..b5d698526 100644
--- a/docs/adr/0119-retire-buyer-terminology.md
+++ b/docs/adr/0131-retire-buyer-terminology.md
@@ -1,4 +1,4 @@
-# ADR 0119: Retire "Buyer" as the reader-facing terminology
+# ADR 0131: Retire "Buyer" as the reader-facing terminology
**Status:** Accepted
**Date:** 2026-08-21
diff --git a/docs/adr/0124-operational-controlled-vocabulary-semantic-layer.md b/docs/adr/0132-operational-controlled-vocabulary-semantic-layer.md
similarity index 97%
rename from docs/adr/0124-operational-controlled-vocabulary-semantic-layer.md
rename to docs/adr/0132-operational-controlled-vocabulary-semantic-layer.md
index 6853f5308..43431ad85 100644
--- a/docs/adr/0124-operational-controlled-vocabulary-semantic-layer.md
+++ b/docs/adr/0132-operational-controlled-vocabulary-semantic-layer.md
@@ -1,5 +1,4 @@
-# ADR 0124: Model operational controlled vocabularies as SKOS concepts
-
+# ADR 0132: Model operational controlled vocabularies as SKOS concepts
## Status
Accepted
diff --git a/docs/adr/0133-external-email-project-lineage-contract.md b/docs/adr/0133-external-email-project-lineage-contract.md
new file mode 100644
index 000000000..324070b1e
--- /dev/null
+++ b/docs/adr/0133-external-email-project-lineage-contract.md
@@ -0,0 +1,48 @@
+# ADR 0133: Publish a bounded external email/project lineage contract
+
+- Status: Accepted
+- Date: 2026-08-21
+
+## Context
+
+Naruon owns customer mail/calendar/file access, canonical message/thread identities, projects, tasks, commitments, provider credentials, authorization, and provider mutations. LineageWeave owns evidence-fused lineage reconstruction and the provenance explaining that reconstruction. Future integration must not give either product direct SQL access to the other's application database, duplicate source authority, or depend on a mutable branch/submodule.
+
+Email thread facts also have different truth semantics from reconstructed semantic continuation. RFC `Message-ID`, `References`, and `In-Reply-To` evidence may establish a caller-observed reply relation, while LineageWeave text/temporal/project signals produce an inferred relation. Flattening both into one unexplained score would make buyer correction and audit impossible.
+
+## Decision
+
+LineageWeave publishes contract version `1.0.0` through:
+
+- `lineageweave.external_lineage_contract` for strict immutable request/result shapes, canonical serialization, bounds, and deterministic digests;
+- `lineageweave.external_lineage_analysis` for adapting caller-authorized evidence to the existing reconstruction kernel.
+
+The initial implementation is a store-agnostic Python package boundary. It performs no database, mailbox, provider, or network operation. A later service or Naruon plugin adapter must preserve the same JSON Schema and truth boundaries.
+
+The caller supplies opaque evidence references, bounded text labels, occurrence and availability clocks, an optional secondary key, an optional project reference, and an optional caller-observed parent relation. Explicit observed parent relations replace an inferred parent for the same child and must form an acyclic graph. Reconstructed continuation remains `inferred`. Project groupings remain `proposed`.
+
+An admitted child with an explicit observed parent is not rescored for an alternative inferred parent and consumes no optional LLM/provider call or inferred-pair budget. The record remains in temporal history and may still be an eligible candidate parent for a later record. This preserves observed authority without weakening downstream lineage reconstruction.
+
+The caller also supplies `maximum_pair_evaluations` in the bounded policy. The package computes the exact inferred candidate-parent pair count after knowledge-cutoff filtering, excluding children whose parent is already caller-observed, and rejects work above the declared budget before any optional LLM/provider call. Contract v1 caps the declared budget at 5,000 pairs.
+
+Historical requests include evidence only when:
+
+```text
+available_at <= knowledge_cutoff
+```
+
+Evidence becoming available after the cutoff is excluded even when it describes an earlier occurrence.
+
+## Consequences
+
+- Naruon can eventually consume a released artifact without exposing credentials or application tables.
+- RFC reply/thread evidence stays distinguishable from semantic lineage.
+- Caller-observed children are never disclosed to an optional model merely to calculate an inferred edge that would be discarded.
+- The optional LLM channel is explicit as `not_requested`, `unavailable`, `not_invoked`, or `completed`; missing output is never zero. `not_invoked` means the channel was allowed and available but no candidate pair required a model call.
+- Canonical serialization and SHA-256 digesting are deterministic for a given request or result. Repeatability of model-backed scores additionally requires a pinned LineageWeave release, adjudicator implementation, provider/model revision, and model-side determinism policy.
+- Explicit parent cycles and analysis work above the caller-approved pair budget fail closed before inference.
+- Project evidence can inform Naruon without mutating authoritative project/task/provider state.
+- The single generic secondary key reflects the current core kernel. Multiple independent typed secondary-key channels remain a future contract revision rather than being silently flattened.
+
+## References
+
+See `docs/doctoring/EXTERNAL_LINEAGE_CONTRACT_REFERENCES.md`.
diff --git a/docs/adr/0094-calendar-open-focuses-event-lineage.md b/docs/adr/0134-calendar-open-focuses-event-lineage.md
similarity index 94%
rename from docs/adr/0094-calendar-open-focuses-event-lineage.md
rename to docs/adr/0134-calendar-open-focuses-event-lineage.md
index 0b7b1a8b9..ddef9fcab 100644
--- a/docs/adr/0094-calendar-open-focuses-event-lineage.md
+++ b/docs/adr/0134-calendar-open-focuses-event-lineage.md
@@ -1,4 +1,4 @@
-# ADR 0094: Opening a Calendar commitment focuses Event Lineage
+# ADR 0134: Opening a Calendar commitment focuses Event Lineage
- Status: Accepted
- Date: 2026-08-19
diff --git a/docs/contracts/README.md b/docs/contracts/README.md
new file mode 100644
index 000000000..017c05eaf
--- /dev/null
+++ b/docs/contracts/README.md
@@ -0,0 +1,13 @@
+# Integration contracts
+
+LineageWeave publishes strict, versioned contracts for separately governed consumers. These contracts do not grant source access and do not replace each consumer's authorization, persistence, provider, or audit authority.
+
+## External lineage analysis v1
+
+- JSON Schema: `external-lineage-analysis-v1.schema.json`
+- Synthetic request: `external-lineage-analysis-v1.example.json`
+- Python parser and immutable types: `lineageweave.external_lineage_contract`
+- Store-agnostic execution adapter: `lineageweave.external_lineage_analysis`
+- Decision record: `docs/adr/0133-external-email-project-lineage-contract.md`
+
+A consumer must submit only bounded evidence it is already authorized to disclose. Outputs retain opaque caller references and explicit `observed`, `inferred`, or `proposed` truth boundaries. The contract performs no source-system access or provider mutation.
diff --git a/docs/contracts/external-lineage-analysis-v1.authorization.md b/docs/contracts/external-lineage-analysis-v1.authorization.md
new file mode 100644
index 000000000..44216d3d2
--- /dev/null
+++ b/docs/contracts/external-lineage-analysis-v1.authorization.md
@@ -0,0 +1,5 @@
+# External lineage analysis v1 authorization contract
+
+LineageWeave does not infer authorization from an opaque reference, source kind, group, project, or caller identity. The caller must authorize evidence before projection and must reauthorize any source drill-through after receiving a result.
+
+The package does not accept provider bearer tokens, browser cookies, mailbox credentials, database DSNs, or caller SQL. A future remote service must use its own audience-scoped service credential and may not forward an end-user token to model providers.
diff --git a/docs/contracts/external-lineage-analysis-v1.consumer-checklist.md b/docs/contracts/external-lineage-analysis-v1.consumer-checklist.md
new file mode 100644
index 000000000..9c89e17cb
--- /dev/null
+++ b/docs/contracts/external-lineage-analysis-v1.consumer-checklist.md
@@ -0,0 +1,11 @@
+# External lineage analysis v1 consumer checklist
+
+- Validate the published JSON Schema before sending or accepting payloads.
+- Submit only evidence the calling principal is authorized to disclose for the declared purpose.
+- Use opaque caller-owned references; never send provider credentials or database locators.
+- Bind historical work to a knowledge cutoff and preserve each record's availability time.
+- Keep RFC/provider thread observations separate from inferred semantic/project lineage.
+- Treat project projections as proposals until the caller's own policy or reviewer accepts them.
+- Preserve the returned artifact digest, LineageWeave version, limitations, and channel evidence.
+- Fail closed on incompatible contract versions.
+- Keep normal caller operation available when LineageWeave is unavailable.
diff --git a/docs/contracts/external-lineage-analysis-v1.data-minimization.md b/docs/contracts/external-lineage-analysis-v1.data-minimization.md
new file mode 100644
index 000000000..522c55e2a
--- /dev/null
+++ b/docs/contracts/external-lineage-analysis-v1.data-minimization.md
@@ -0,0 +1,12 @@
+# External lineage analysis v1 data minimization
+
+Consumers should prefer the minimum evidence needed for a declared analysis scope:
+
+- opaque evidence and grouping references;
+- offset-aware occurrence and availability times;
+- RFC/provider relation evidence when present;
+- bounded subject/title labels or caller-computed text features;
+- optional project or secondary-key references;
+- optional participant, body, or attachment evidence only when the caller's purpose and policy explicitly permit it.
+
+The contract does not require a mailbox dump, full thread body, recipient list, provider URL, or attachment bytes. Omitted evidence is unavailable and cannot appear in output.
diff --git a/docs/contracts/external-lineage-analysis-v1.example.json b/docs/contracts/external-lineage-analysis-v1.example.json
new file mode 100644
index 000000000..b9b90bdce
--- /dev/null
+++ b/docs/contracts/external-lineage-analysis-v1.example.json
@@ -0,0 +1,41 @@
+{
+ "contract_version": "1.0.0",
+ "analysis_id": "analysis:synthetic-email-lineage-001",
+ "analysis_scope_code": "email_lineage",
+ "knowledge_cutoff": "2026-08-20T09:30:00Z",
+ "policy": {
+ "candidate_window": 50,
+ "maximum_pair_evaluations": 1000,
+ "minimum_fused_score": 0.3,
+ "allow_llm": false
+ },
+ "records": [
+ {
+ "evidence_ref": "email:synthetic-001",
+ "group_ref": "workspace:synthetic",
+ "source_kind_code": "email",
+ "truth_status_code": "observed",
+ "label": "Synthetic proposal review",
+ "occurred_at": "2026-08-20T09:00:00Z",
+ "available_at": "2026-08-20T09:01:00Z",
+ "secondary_key": "provider-thread:synthetic",
+ "project_ref": "project:synthetic",
+ "explicit_parent": null
+ },
+ {
+ "evidence_ref": "email:synthetic-002",
+ "group_ref": "workspace:synthetic",
+ "source_kind_code": "email",
+ "truth_status_code": "observed",
+ "label": "Re: Synthetic proposal review",
+ "occurred_at": "2026-08-20T09:05:00Z",
+ "available_at": "2026-08-20T09:06:00Z",
+ "secondary_key": "provider-thread:synthetic",
+ "project_ref": "project:synthetic",
+ "explicit_parent": {
+ "evidence_ref": "email:synthetic-001",
+ "relation_code": "rfc_reply"
+ }
+ }
+ ]
+}
diff --git a/docs/contracts/external-lineage-analysis-v1.limitations.md b/docs/contracts/external-lineage-analysis-v1.limitations.md
new file mode 100644
index 000000000..fd3864f77
--- /dev/null
+++ b/docs/contracts/external-lineage-analysis-v1.limitations.md
@@ -0,0 +1,11 @@
+# External lineage analysis v1 limitations
+
+- The contract does not read IMAP, JMAP, CalDAV, Naruon, or other provider systems.
+- It does not authenticate users, authorize tenant access, persist jobs, or retry remote work.
+- It does not make semantic lineage equivalent to RFC reply/thread identity.
+- It does not turn project groupings, responsibility context, or reconstructed edges into authoritative caller facts.
+- It does not infer unavailable evidence as a zero-valued channel.
+- It does not guarantee causal relations; reconstructed continuation is an evidence-weighted related-history hypothesis.
+- Canonical request/result serialization and digests are deterministic, but an optional remote adjudication channel is not automatically repeatable unless the consumer pins the LineageWeave artifact, adjudicator, provider/model revision, and determinism policy.
+- Contract v1 does not carry a remote provider/model receipt inside the result; production wrappers must retain that provenance alongside the result digest before model-backed integration is enabled.
+- It does not replace Naruon's canonical email identity, project/task/commitment state, provider mutation, or reconciliation authority.
diff --git a/docs/contracts/external-lineage-analysis-v1.operability.md b/docs/contracts/external-lineage-analysis-v1.operability.md
new file mode 100644
index 000000000..7e3de54f6
--- /dev/null
+++ b/docs/contracts/external-lineage-analysis-v1.operability.md
@@ -0,0 +1,5 @@
+# External lineage analysis v1 operability boundary
+
+The pure package entry point is synchronous and bounded. Remote or model-backed production use must wrap it in a separately reviewed service or plugin lifecycle with durable idempotency, cancellation, timeout, retry classification, rate limiting, resource budgets, artifact retention, OpenTelemetry signals, and user-visible degraded states.
+
+A consumer must not call optional model-backed pair adjudication directly on an unbounded web request path. LineageWeave #289 tracks the durable asynchronous reconstruction requirement for product persistence, and Naruon #1437 requires an equivalent consumer-side job receipt before integration is enabled.
diff --git a/docs/contracts/external-lineage-analysis-v1.schema.json b/docs/contracts/external-lineage-analysis-v1.schema.json
new file mode 100644
index 000000000..05d74e6a1
--- /dev/null
+++ b/docs/contracts/external-lineage-analysis-v1.schema.json
@@ -0,0 +1,255 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://contextualwisdomlab.org/schemas/external-lineage-analysis-v1.schema.json",
+ "title": "LineageWeave External Lineage Analysis Request v1",
+ "description": "Bounded caller-authorized evidence for store-agnostic lineage analysis. The response shape is available as $defs.LineageAnalysisResult.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "contract_version",
+ "analysis_id",
+ "analysis_scope_code",
+ "policy",
+ "records"
+ ],
+ "properties": {
+ "contract_version": {"const": "1.0.0"},
+ "analysis_id": {"$ref": "#/$defs/OpaqueReference"},
+ "analysis_scope_code": {
+ "type": "string",
+ "enum": ["email_lineage", "project_history", "generic_lineage"]
+ },
+ "knowledge_cutoff": {
+ "anyOf": [
+ {"type": "string", "format": "date-time"},
+ {"type": "null"}
+ ]
+ },
+ "policy": {"$ref": "#/$defs/LineageAnalysisPolicy"},
+ "records": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 500,
+ "items": {"$ref": "#/$defs/LineageEvidenceRecord"}
+ }
+ },
+ "$defs": {
+ "OpaqueReference": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 160,
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@+\\-]*$"
+ },
+ "NullableOpaqueReference": {
+ "anyOf": [
+ {"$ref": "#/$defs/OpaqueReference"},
+ {"type": "null"}
+ ]
+ },
+ "ExplicitParent": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["evidence_ref", "relation_code"],
+ "properties": {
+ "evidence_ref": {"$ref": "#/$defs/OpaqueReference"},
+ "relation_code": {
+ "type": "string",
+ "enum": ["rfc_reply", "provider_reply", "manual_parent"]
+ }
+ }
+ },
+ "LineageAnalysisPolicy": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "candidate_window",
+ "maximum_pair_evaluations",
+ "minimum_fused_score",
+ "allow_llm"
+ ],
+ "properties": {
+ "candidate_window": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 200
+ },
+ "maximum_pair_evaluations": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 5000
+ },
+ "minimum_fused_score": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ },
+ "allow_llm": {"type": "boolean"}
+ }
+ },
+ "LineageEvidenceRecord": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "evidence_ref",
+ "group_ref",
+ "source_kind_code",
+ "truth_status_code",
+ "label",
+ "occurred_at",
+ "available_at"
+ ],
+ "properties": {
+ "evidence_ref": {"$ref": "#/$defs/OpaqueReference"},
+ "group_ref": {"$ref": "#/$defs/OpaqueReference"},
+ "source_kind_code": {
+ "type": "string",
+ "enum": ["email", "task", "commitment", "project_event", "generic"]
+ },
+ "truth_status_code": {
+ "type": "string",
+ "enum": ["observed", "authoritative_in_caller"]
+ },
+ "label": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2000
+ },
+ "occurred_at": {"type": "string", "format": "date-time"},
+ "available_at": {"type": "string", "format": "date-time"},
+ "secondary_key": {"$ref": "#/$defs/NullableOpaqueReference"},
+ "project_ref": {"$ref": "#/$defs/NullableOpaqueReference"},
+ "explicit_parent": {
+ "anyOf": [
+ {"$ref": "#/$defs/ExplicitParent"},
+ {"type": "null"}
+ ]
+ }
+ }
+ },
+ "ChannelEvidence": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["channel_code", "score", "weight", "contribution"],
+ "properties": {
+ "channel_code": {"type": "string", "minLength": 1, "maxLength": 64},
+ "score": {"type": "number", "minimum": 0, "maximum": 1},
+ "weight": {"type": "number", "minimum": 0, "maximum": 1},
+ "contribution": {"type": "number", "minimum": 0, "maximum": 1}
+ }
+ },
+ "LineageEdgeResult": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "parent_evidence_ref",
+ "child_evidence_ref",
+ "relation_type_code",
+ "truth_status_code",
+ "fused_score",
+ "channel_evidence"
+ ],
+ "properties": {
+ "parent_evidence_ref": {"$ref": "#/$defs/OpaqueReference"},
+ "child_evidence_ref": {"$ref": "#/$defs/OpaqueReference"},
+ "relation_type_code": {"type": "string", "minLength": 1, "maxLength": 64},
+ "truth_status_code": {
+ "type": "string",
+ "enum": ["observed", "inferred"]
+ },
+ "fused_score": {"type": "number", "minimum": 0, "maximum": 1},
+ "channel_evidence": {
+ "type": "array",
+ "minItems": 1,
+ "items": {"$ref": "#/$defs/ChannelEvidence"}
+ }
+ }
+ },
+ "ProjectProjection": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["group_ref", "project_ref", "evidence_refs", "truth_status_code"],
+ "properties": {
+ "group_ref": {"$ref": "#/$defs/OpaqueReference"},
+ "project_ref": {"$ref": "#/$defs/OpaqueReference"},
+ "evidence_refs": {
+ "type": "array",
+ "minItems": 1,
+ "items": {"$ref": "#/$defs/OpaqueReference"},
+ "uniqueItems": true
+ },
+ "truth_status_code": {"const": "proposed"}
+ }
+ },
+ "LineageLimitation": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["limitation_code", "evidence_ref", "message"],
+ "properties": {
+ "limitation_code": {"type": "string", "minLength": 1, "maxLength": 96},
+ "evidence_ref": {"$ref": "#/$defs/NullableOpaqueReference"},
+ "message": {"type": "string", "minLength": 1, "maxLength": 500}
+ }
+ },
+ "LineageAnalysisResult": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "contract_version",
+ "analysis_id",
+ "analysis_scope_code",
+ "knowledge_cutoff",
+ "included_evidence_refs",
+ "excluded_evidence_refs",
+ "llm_status_code",
+ "edges",
+ "project_projections",
+ "limitations",
+ "result_digest"
+ ],
+ "properties": {
+ "contract_version": {"const": "1.0.0"},
+ "analysis_id": {"$ref": "#/$defs/OpaqueReference"},
+ "analysis_scope_code": {
+ "type": "string",
+ "enum": ["email_lineage", "project_history", "generic_lineage"]
+ },
+ "knowledge_cutoff": {
+ "anyOf": [
+ {"type": "string", "format": "date-time"},
+ {"type": "null"}
+ ]
+ },
+ "included_evidence_refs": {
+ "type": "array",
+ "items": {"$ref": "#/$defs/OpaqueReference"},
+ "uniqueItems": true
+ },
+ "excluded_evidence_refs": {
+ "type": "array",
+ "items": {"$ref": "#/$defs/OpaqueReference"},
+ "uniqueItems": true
+ },
+ "llm_status_code": {
+ "type": "string",
+ "enum": ["not_requested", "unavailable", "not_invoked", "completed"]
+ },
+ "edges": {
+ "type": "array",
+ "items": {"$ref": "#/$defs/LineageEdgeResult"}
+ },
+ "project_projections": {
+ "type": "array",
+ "items": {"$ref": "#/$defs/ProjectProjection"}
+ },
+ "limitations": {
+ "type": "array",
+ "items": {"$ref": "#/$defs/LineageLimitation"}
+ },
+ "result_digest": {
+ "type": "string",
+ "pattern": "^sha256:[0-9a-f]{64}$"
+ }
+ }
+ }
+ }
+}
diff --git a/docs/contracts/external-lineage-analysis-v1.security.md b/docs/contracts/external-lineage-analysis-v1.security.md
new file mode 100644
index 000000000..c87ffbab2
--- /dev/null
+++ b/docs/contracts/external-lineage-analysis-v1.security.md
@@ -0,0 +1,22 @@
+# External lineage analysis v1 security boundary
+
+The contract is an analysis interface, not an authorization interface.
+
+## Caller responsibilities
+
+- authenticate the caller and authorize every submitted evidence record;
+- enforce tenant, workspace, purpose, retention, and export policy;
+- minimize text and participant evidence according to data classification;
+- retain provider credentials, raw access tokens, browser sessions, and unrelated mailbox content inside the caller boundary;
+- pin and record the immutable LineageWeave artifact used for an analysis;
+- retain adjudicator and provider/model provenance beside any model-backed result;
+- verify the returned contract version and result digest before persistence or display.
+
+## LineageWeave boundary
+
+- rejects unsafe opaque references, unknown fields, invalid timestamps, duplicate evidence, and over-budget inferred work;
+- returns only references present in the admitted request from the supported analysis adapter;
+- distinguishes observed caller relations from inferred reconstruction;
+- does not rescore or disclose a caller-observed child to the optional LLM merely to generate an alternative edge that would be discarded;
+- never promotes a proposed project projection to caller authority;
+- performs no provider mutation and receives no provider credential through this contract.
diff --git a/docs/contracts/external-lineage-analysis-v1.versioning.md b/docs/contracts/external-lineage-analysis-v1.versioning.md
new file mode 100644
index 000000000..9b0640ee3
--- /dev/null
+++ b/docs/contracts/external-lineage-analysis-v1.versioning.md
@@ -0,0 +1,10 @@
+# External lineage analysis versioning policy
+
+- `contract_version` follows semantic versioning independently from the LineageWeave package version.
+- Unknown major versions fail closed.
+- Additive optional fields require a new minor contract revision and corresponding consumer fixtures.
+- Vocabulary changes, field semantic changes, required-field changes, digest changes, or truth-status changes require a new major contract version.
+- A released schema, example, parser, serializer, digest algorithm, and consumer fixtures remain immutable for that contract version.
+- Consumers must record both the contract version and immutable LineageWeave package/service artifact identity. The contract version alone does not identify the reconstruction implementation.
+- Model-backed runs must additionally retain the adjudicator implementation and provider/model revision outside the v1 result payload; canonical digest determinism must not be described as provider repeatability.
+- Naruon and other consumers pin an immutable LineageWeave release or service artifact and verify compatibility before enabling the integration.
diff --git a/docs/doctoring/CUSTOMER_HIERARCHY_REFERENCES.md b/docs/doctoring/CUSTOMER_HIERARCHY_REFERENCES.md
new file mode 100644
index 000000000..f30ffcdfb
--- /dev/null
+++ b/docs/doctoring/CUSTOMER_HIERARCHY_REFERENCES.md
@@ -0,0 +1,39 @@
+# Customer hierarchy standards and research traceability
+
+**Decision:** ADR 0124 — Cycle-safe customer master tree projection
+**Reviewed:** 2026-08-21
+**Figma file ID:** `SBpgot7uTvMxEaxUwvoc0S`
+
+## Traceability
+
+| External source | Product decision | Implementation evidence | Current limitation |
+|---|---|---|---|
+| W3C Organization Ontology | Real corporate entities are `org:Organization` instances; `parent_entity_id` projects through `subOrganizationOf`, a specialization of `org:subOrganizationOf`. | `docs/ontology/lineageweave-kg.ttl`, `tests/test_ontology_interoperability.py` | One parent context only; no effective-dated legal/operating hierarchy contexts yet. |
+| W3C SKOS | Group, Company, and Plant are classification concepts, not the real customer organizations. | `CorporateEntityLevel`, `hasEntityLevel`, and level concepts in the ontology | Allowed level-transition rules are not yet encoded. |
+| W3C SHACL | Closed-world validation requires exactly one level and at most one parent in the published projection. | `docs/ontology/lineageweave-kg.shacl.ttl`, ontology interoperability tests | Global acyclicity is not yet a SHACL constraint; the buyer projection therefore remains defensive. |
+| WAI-ARIA APG Tree View Pattern | Use `tree`, nested `treeitem`/`group`, roving focus, Arrow/Home/End navigation, and branch `aria-expanded`. | `CustomerMasterTree.tsx` and component tests | Type-ahead navigation is not included in this bounded change. |
+| WCAG 2.2 | All hierarchy and evidence actions are keyboard operable with a visible focus target and preserve the current selection. | Component keyboard tests and existing focus design tokens | Full assistive-technology browser acceptance remains a release-level check. |
+
+## Product truth boundary
+
+The ontology and SHACL graphs describe and validate the relational projection; PostgreSQL remains the
+source of record. The browser never writes a repaired parent relation. Missing-parent, self-parent, or
+cyclic edges are displayed as `unresolved` roots so an authorized customer cannot disappear and a
+replacement parent is not invented.
+
+## References — APA 7th
+
+World Wide Web Consortium. (2009). *SKOS simple knowledge organization system reference*.
+https://www.w3.org/TR/skos-reference/
+
+World Wide Web Consortium. (2014). *The Organization Ontology*.
+https://www.w3.org/TR/vocab-org/
+
+World Wide Web Consortium. (2017). *Shapes Constraint Language (SHACL)*.
+https://www.w3.org/TR/shacl/
+
+World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2*.
+https://www.w3.org/TR/WCAG22/
+
+World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *Tree view pattern*.
+https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
diff --git a/docs/doctoring/EXTERNAL_LINEAGE_CONTRACT_REFERENCES.md b/docs/doctoring/EXTERNAL_LINEAGE_CONTRACT_REFERENCES.md
new file mode 100644
index 000000000..0b88b76e3
--- /dev/null
+++ b/docs/doctoring/EXTERNAL_LINEAGE_CONTRACT_REFERENCES.md
@@ -0,0 +1,23 @@
+# External Lineage Contract References
+
+## Product traceability
+
+| Source | Product decision |
+|---|---|
+| RFC 3339 | Require offset-aware occurrence, availability, and knowledge-cutoff timestamps. |
+| RFC 5322 | Preserve Internet-message identity and reply metadata as caller-observed evidence rather than semantic inference. |
+| RFC 5256 | Keep standards-based email threading evidence distinct from LineageWeave reconstruction. |
+| W3C PROV-O | Return evidence references, truth status, analysis identity, and provenance-friendly result artifacts. |
+| W3C OWL-Time | Separate occurrence time from evidence availability and enforce cutoff safety by availability. |
+
+## References — APA 7th
+
+Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). RFC Editor. https://doi.org/10.17487/RFC3339
+
+Crispin, M., & Murchison, K. (2008). *Internet Message Access Protocol—SORT and THREAD extensions* (RFC 5256). RFC Editor. https://doi.org/10.17487/RFC5256
+
+Resnick, P. W. (2008). *Internet message format* (RFC 5322). RFC Editor. https://doi.org/10.17487/RFC5322
+
+World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/
+
+World Wide Web Consortium. (2017). *Time ontology in OWL*. https://www.w3.org/TR/owl-time/
diff --git a/docs/doctoring/EXTERNAL_LINEAGE_CONTRACT_TRACEABILITY.md b/docs/doctoring/EXTERNAL_LINEAGE_CONTRACT_TRACEABILITY.md
new file mode 100644
index 000000000..37587f0d1
--- /dev/null
+++ b/docs/doctoring/EXTERNAL_LINEAGE_CONTRACT_TRACEABILITY.md
@@ -0,0 +1,12 @@
+# External Lineage Contract Traceability
+
+| Requirement | Product decision | Implementation | Evidence |
+|---|---|---|---|
+| Caller authorization remains authoritative | Accept only caller-projected evidence and opaque references | `lineageweave.external_lineage_contract` | strict parser and hostile-input tests |
+| Historical answers exclude future evidence | Filter by `available_at <= knowledge_cutoff` | `lineageweave.external_lineage_analysis` | cutoff inclusion/exclusion tests |
+| RFC relations remain distinct | Explicit parent relations serialize as observed relation codes | execution adapter | observed-parent precedence tests |
+| Semantic lineage remains inferred | Reconstructed edges use `truth_status_code=inferred` | execution adapter | result contract tests |
+| Optional LLM absence is honest | Return `not_requested`, `unavailable`, or `not_invoked`; do not fabricate a score | execution adapter | LLM policy and schema-vocabulary tests |
+| Work is bounded before provider calls | Enforce record count, candidate window, and maximum pair evaluations | parser and execution adapter | pair-budget tests |
+| Project state is not silently mutated | Return only `proposed` project projections | contract/result validator | project truth-status tests |
+| Consumer compatibility is machine-checkable | Publish JSON Schema and canonical request/result digests | schema and contract module | schema drift and digest tests |
diff --git a/docs/doctoring/PRODUCT_TECHNICAL_GAP_REFERENCES.md b/docs/doctoring/PRODUCT_TECHNICAL_GAP_REFERENCES.md
new file mode 100644
index 000000000..73f442d20
--- /dev/null
+++ b/docs/doctoring/PRODUCT_TECHNICAL_GAP_REFERENCES.md
@@ -0,0 +1,24 @@
+# Product technical-gap references
+
+These references are the normative basis for the semantic-unit boundary in
+[ADR 0103](../adr/0103-semantic-document-evidence-contract.md). Dates and
+versions are recorded so a later standards refresh can be reviewed rather
+than silently changing parser behavior.
+
+## APA 7th edition
+
+CommonMark. (2024). *CommonMark spec (Version 0.31.2)*. https://spec.commonmark.org/0.31.2/
+
+WHATWG. (2026). *HTML: Living Standard*. https://html.spec.whatwg.org/multipage/
+
+## Applied mapping
+
+| Source | Boundary used in LineageWeave |
+| --- | --- |
+| CommonMark (2024) | Recognizable Markdown block/header/separator shape; unrecognized dialects remain source text. |
+| WHATWG (2026) | HTML list, table-row, and `sup` semantics; source order and element identity are retained as unit metadata. |
+
+These standards define syntax and semantics, not an LLM extraction license.
+Provider-derived summaries, image descriptions, project boundaries, and
+5W1H values still require contextual-orchestrator provenance and explicit
+source evidence.
diff --git a/docs/ontology/lineageweave-kg.shacl.ttl b/docs/ontology/lineageweave-kg.shacl.ttl
new file mode 100644
index 000000000..0e59b5ce3
--- /dev/null
+++ b/docs/ontology/lineageweave-kg.shacl.ttl
@@ -0,0 +1,40 @@
+@prefix : {t("Loading related posts...")} {t("No linked posts yet.")} {t("Authorized customer scope")} {t("Customer entities available to this account.")}
+ {t("Authorized customer entities are current. Open a related post to read Event Lineage.")}
+ {error} {t("Loading customer master...")} {t("No customer entities are connected to this account.")} {t("Evidence-grounded questions")} {t("Thinking...")} {exchange.error} {response.answer_text} {t(response.next_action)}
+ {t(response.next_action)}
+
+ {t("Authorized cited posts are current. Open a cited post to read Event Lineage.")}
+
- {relatedPosts.map((related) => (
-
- ) : null}
-
- {children.map((child) => (
-
- ) : null}
- {t("Customer master")}
- {buildCustomerEntityTree(master.corporate_entities).map((node) => (
-
+ {t("Ask Agent")}
@@ -4753,14 +4624,26 @@ function AskAgentPanel({
{exchange.status === "pending" ? {t("Cited posts")}
{response.cited_posts.map((post) => (
| + {cell} + | + ))} +
|---|
| + {cell} + | + ))} +
{copy.hierarchyKicker}
+{copy.focusKicker}
++ {selectedEntity.corporate_entity_code} · {selectedEntity.entity_level_label} +
+ + {selectedNode.hierarchyIssue ? t("unresolved") : copy.verifiedMaster} + +{copy.unresolvedRelation}
+ ) : null} + +{copy.noParent}
} +{copy.noChildren}
+ )} +{copy.selectCustomerPrompt}
+ )} +1 Source note
")).toEqual([ + { kind: "text", text: "1 Source note", role: "footnote" }, + ]); + }); + + it("preserves explicit metric superscripts and subscripts", () => { + expect(splitPostBody("Volume: 5m3, index m3.
")).toEqual([ + { kind: "text", text: "Volume: 5m³, index m₃." }, + ]); + }); + + it("normalizes plain-text metric superscripts and subscripts", () => { + expect(splitPostBody("Volume: 5m^3, index m_3, braced m^{2}.
")).toEqual([ + { kind: "text", text: "Volume: 5m³, index m₃, braced m²." }, + ]); + }); + + it("preserves HTML and Word footnote blocks as footnote paragraphs", () => { + expect( + splitPostBody( + "Body evidence.
" + + "Next action.
", + ), + ).toEqual([ + { kind: "text", text: "Body evidence." }, + { kind: "text", text: "HTML note.", role: "footnote" }, + { kind: "text", text: "Word note.", role: "footnote" }, + { kind: "text", text: "Next action." }, + ]); + }); + + it("limits numeric superscript footnote roles to their source paragraph", () => { + expect( + splitPostBody( + "Evidence remains attached to the source.
" + + "1 Source note.
" + + "Continue with the next source action.
", + ), + ).toEqual([ + { kind: "text", text: "Evidence remains attached to the source." }, + { kind: "text", text: "1 Source note.", role: "footnote" }, + { kind: "text", text: "Continue with the next source action." }, + ]); + }); + + it("keeps exporter list containers in the visible nesting hierarchy", () => { + expect(splitPostBody("or - #
First block of text.
Second block of text.
Nested paragraph text.
Child text
No. | Company |
Items:
| "
+ "Owner |
| "
+ "Owner |
Body text
*Tier 2: follow-up note
") assert [(chunk.label, chunk.text) for chunk in chunks] == [ ("p", "Body text"), @@ -112,6 +164,154 @@ def test_chunk_by_dom_labels_markerless_footnotes() -> None: ] +def test_chunk_by_dom_labels_numeric_superscript_footnotes() -> None: + """A leading numeric superscript assigns the footnote label.""" + chunks = chunk_by_dom("1 Source note attached to the record.
") + + assert [(chunk.label, chunk.text) for chunk in chunks] == [ + ("footnote", "1 Source note attached to the record."), + ] + + +def test_chunk_by_dom_labels_numeric_superscript_after_body_text() -> None: + """A numeric superscript anywhere in a paragraph marks its evidence role.""" + chunks = chunk_by_dom("Body claim1 source note.
") + + assert [(chunk.label, chunk.text) for chunk in chunks] == [ + ("footnote", "Body claim1 source note."), + ] + + +def test_chunk_by_dom_does_not_treat_non_numeric_superscript_as_footnote() -> None: + """A formula superscript remains ordinary prose.""" + chunks = chunk_by_dom("Formula xn remains prose.
") + + assert [(chunk.label, chunk.text) for chunk in chunks] == [ + ("p", "Formula xn remains prose."), + ] + + +def test_chunk_by_dom_preserves_explicit_metric_superscripts() -> None: + """A unit exponent remains searchable mathematical evidence.""" + chunks = chunk_by_dom("Volume: 5m3.
") + + assert [(chunk.label, chunk.text) for chunk in chunks] == [ + ("p", "Volume: 5m³."), + ] + + +def test_chunk_by_dom_preserves_explicit_metric_subscripts() -> None: + """A unit subscript is retained without changing ordinary footnotes.""" + chunks = chunk_by_dom("Index m3 is measured.
") + + assert [(chunk.label, chunk.text) for chunk in chunks] == [ + ("p", "Index m₃ is measured."), + ] + + +def test_chunk_by_source_body_normalizes_plain_metric_scripts() -> None: + """Plain-text metric scripts retain searchable exponent/index semantics.""" + chunks = chunk_by_source_body("Volume: 5m^3; index m_3; braced m^{2}.") + + assert [(chunk.label, chunk.text) for chunk in chunks] == [ + ("", "Volume: 5m³; index m₃; braced m²."), + ] + + +def test_chunk_by_source_body_normalizes_metric_scripts_in_markdown_table_cells() -> None: + """Markdown table cells retain the same searchable metric semantics as prose.""" + chunks = chunk_by_source_body( + "| Metric | Index |\n| --- | --- |\n| 5m^3 | m_3 |" + ) + + assert [(chunk.label, chunk.text) for chunk in chunks] == [ + ("tr", "Metric | Index"), + ("tr", "5m³ | m₃"), + ] + + +def test_chunk_by_dom_preserves_nested_list_order_and_depth() -> None: + """Nested list items retain source order and increasing depth.""" + chunks = chunk_by_dom( + "Body text
" @@ -173,6 +373,7 @@ def test_chunk_by_dom_preserves_explicit_metric_subscripts() -> None: def test_chunk_by_dom_word_table_rows_also_group_cells() -> None: + """WordprocessingML table cells group by their source row.""" html = "Level one
Level two
" chunks = chunk_by_dom(html) @@ -190,6 +392,7 @@ def test_chunk_by_dom_keeps_indentation_as_metadata_not_embedding_text() -> None def test_chunk_by_dom_reads_html_and_word_indentation_declarations() -> None: + """HTML and Word indentation declarations map to comparable units.""" html = ( 'HTML
' 'HTML evidence
") + + assert [(chunk.label, chunk.text) for chunk in chunks] == [("p", "HTML evidence")] + + def test_chunk_by_dom_joins_visual_continuation_lines_but_keeps_list_items() -> None: + """Visual wraps join while authored list starts retain boundaries.""" html = ( '1. 배경
'
" 1) 기존 대차는 이전이 필요함
"
@@ -269,6 +499,7 @@ def test_chunk_by_dom_joins_visual_continuation_lines_but_keeps_list_items() ->
def test_normalize_semantic_text_removes_visual_hanging_indent_breaks() -> None:
+ """Hanging-indent line wraps normalize without flattening list items."""
text = (
"1. 배경\n\n"
" 1) 기존 대차는 이전이 필요함\n"
@@ -284,16 +515,19 @@ def test_normalize_semantic_text_removes_visual_hanging_indent_breaks() -> None:
def test_normalize_semantic_text_preserves_blank_paragraph_boundaries() -> None:
+ """Blank lines continue to separate authored paragraphs."""
assert normalize_semantic_text("첫 문단\n\n둘째 문단") == "첫 문단\n\n둘째 문단"
def test_normalize_semantic_text_does_not_embed_visual_indentation_markers() -> None:
+ """Presentation-only non-breaking spaces do not enter semantic text."""
assert normalize_semantic_text("\xa0\xa0계속되는 문장\n\xa0\xa0\xa0\xa0다음 줄") == (
"계속되는 문장 다음 줄"
)
def test_chunk_by_dom_does_not_infer_marker_depth_without_source_whitespace() -> None:
+ """Marker shape alone cannot invent indentation depth."""
chunks = chunk_by_dom("
1. Root
1) Child
- Detail
") + + assert [(chunk.label, chunk.text) for chunk in chunks] == [("", "1")] + + +def test_chunk_by_dom_decodes_deeply_escaped_entities_with_a_bounded_loop() -> None: + """Nested HTML entity escapes decode without an unbounded parser loop.""" + assert [chunk.text for chunk in chunk_by_dom("
&amp;amp;
")] == ["&"] + + def test_chunk_by_dom_falls_back_for_inline_only_markup() -> None: + """Inline-only markup falls back to one plain-text unit.""" chunks = chunk_by_dom("First inline block.Second inline block.") assert len(chunks) == 1 @@ -314,6 +563,7 @@ def test_chunk_by_dom_falls_back_for_inline_only_markup() -> None: def test_chunk_by_dom_flushes_unclosed_block_at_end_of_document() -> None: + """EOF flushes content from an unclosed source block.""" chunks = chunk_by_dom("Before the picture.
" @@ -339,6 +590,7 @@ def test_chunk_by_dom_interleaves_images_with_text_in_document_order() -> None: def test_chunk_by_dom_labels_text_chunks_with_their_tag_name() -> None: + """DOM text chunks expose their source tag as the unit label.""" html = "A paragraph.
Text.

Plain paragraph.
") assert chunks[0].style is None def test_chunk_by_dom_splits_on_heading_boundaries_and_labels_the_level() -> None: + """Heading boundaries preserve their source level label.""" html = "Body text follows.
" chunks = chunk_by_dom(html) diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py index e3618aa82..a4f6bcfad 100644 --- a/tests/test_contextual_orchestrator_start.py +++ b/tests/test_contextual_orchestrator_start.py @@ -52,6 +52,15 @@ def test_gateway_api_key_accepts_local_compatibility_alias(monkeypatch) -> None: assert module._pop_first_env("LLM_GATEWAY_API_KEY", "LLM_API_KEY") == "compatibility-key" +def test_env_file_quotes_are_not_part_of_transport_values(monkeypatch) -> None: + module = _load_start_module() + monkeypatch.setenv("LLM_GATEWAY_API_KEY", "'provider-key'") + monkeypatch.setenv("LLM_GATEWAY_API_URL", '"https://gateway.example/v1"') + + assert module._pop_first_env("LLM_GATEWAY_API_KEY") == "provider-key" + assert module._pop_first_env("LLM_GATEWAY_API_URL") == "https://gateway.example/v1" + + def test_bootstrap_registers_embedding_agent_before_deleting_secrets(monkeypatch) -> None: module = _load_start_module() captured: dict[str, object] = {} @@ -90,9 +99,11 @@ def serve() -> None: monkeypatch.setattr(module, "Path", FakePath) monkeypatch.setattr(sys, "argv", ["start.py"]) monkeypatch.setenv("LLM_GATEWAY_API_KEY", "provider-key") - monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_TOKEN", "orchestrator-token") + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_TOKEN", "'orchestrator-token'") monkeypatch.setenv("LLM_GATEWAY_API_URL", "https://gateway.example") - monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_MODEL", "embedding-model") + monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_MODEL", "'embedding-model'") + monkeypatch.setenv("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "'2048'") + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES", '"65536"') module.main() @@ -100,6 +111,9 @@ def serve() -> None: assert isinstance(argv, list) assert "--embedding-provider-url" not in argv assert "--embedding-model" not in argv + assert argv[argv.index("--auth-token") + 1] == "orchestrator-token" + assert argv[argv.index("--max-output-tokens") + 1] == "2048" + assert argv[argv.index("--max-body-bytes") + 1] == "65536" assert captured["credentials"] == [ ("NVIDIA_NIM_API_KEY", "provider-key"), ("LLM_GATEWAY_API_KEY", "provider-key"), diff --git a/tests/test_external_lineage_analysis.py b/tests/test_external_lineage_analysis.py new file mode 100644 index 000000000..937374332 --- /dev/null +++ b/tests/test_external_lineage_analysis.py @@ -0,0 +1,729 @@ +"""Execution tests for the external Naruon-facing lineage adapter.""" + +from __future__ import annotations + +import pytest + +from lineageweave.external_lineage_analysis import ( + _channel_evidence, + analyze_external_lineage, +) +from lineageweave.external_lineage_contract import ( + LineageContractError, + parse_lineage_analysis_request, + request_digest, + result_digest, +) + + +class AvailableLlm: + """Deterministic available adjudication channel for contract tests.""" + + available = True + + def judge(self, candidate_label: str, record_label: str) -> float: + """Return a high score for labels sharing their first token.""" + + return ( + 0.9 + if candidate_label.split()[0] == record_label.split()[0] + else 0.1 + ) + + +class InvalidLlm: + """Available client returning an invalid score for fail-closed coverage.""" + + available = True + + def judge(self, candidate_label: str, record_label: str) -> float: + """Return an intentionally invalid value.""" + + return 2.0 + + +class TextLlm: + """Available client returning a non-numeric score.""" + + available = True + + def judge(self, candidate_label: str, record_label: str) -> str: + """Return an intentionally malformed score.""" + + return "unknown" + + +class BrokenProviderLlm: + """Available client surfacing an unexpected raw provider failure.""" + + available = True + + def judge(self, candidate_label: str, record_label: str) -> float: + """Raise a raw provider message that must not cross the contract.""" + + raise RuntimeError("provider secret response body") + + +class CountingLlm: + """Available client recording calls for pre-provider budget tests.""" + + available = True + + def __init__(self) -> None: + """Initialize an empty call counter.""" + + self.call_count = 0 + + def judge(self, candidate_label: str, record_label: str) -> float: + """Count one call and return a bounded score.""" + + self.call_count += 1 + return 0.5 + + +def _record( + evidence_ref: str, + label: str, + occurred_at: str, + *, + available_at: str | None = None, + secondary_key: str | None = "thread:opaque", + project_ref: str | None = "project:opaque", + explicit_parent: dict[str, str] | None = None, + group_ref: str = "workspace:demo", +) -> dict[str, object]: + return { + "evidence_ref": evidence_ref, + "group_ref": group_ref, + "source_kind_code": "email", + "truth_status_code": "observed", + "label": label, + "occurred_at": occurred_at, + "available_at": available_at or occurred_at, + "secondary_key": secondary_key, + "project_ref": project_ref, + "explicit_parent": explicit_parent, + } + + +def _request( + records: list[dict[str, object]], + *, + cutoff: str | None = None, + allow_llm: bool = False, + scope: str = "email_lineage", +): + return parse_lineage_analysis_request( + { + "contract_version": "1.0.0", + "analysis_id": "analysis:integration-001", + "analysis_scope_code": scope, + "knowledge_cutoff": cutoff, + "policy": { + "candidate_window": 50, + "maximum_pair_evaluations": 1000, + "minimum_fused_score": 0.1, + "allow_llm": allow_llm, + }, + "records": records, + } + ) + + +def test_cutoff_uses_available_time_and_discloses_excluded_evidence() -> None: + request = _request( + [ + _record( + "email:early", + "Project update", + "2026-08-18T09:00:00Z", + available_at="2026-08-18T09:01:00Z", + ), + _record( + "email:late", + "Earlier event reported late", + "2026-08-17T09:00:00Z", + available_at="2026-08-20T09:00:00Z", + ), + ], + cutoff="2026-08-19T00:00:00Z", + ) + + result = analyze_external_lineage(request) + + assert result.included_evidence_refs == ("email:early",) + assert result.excluded_evidence_refs == ("email:late",) + assert result.edges == () + assert [ + (item.limitation_code, item.evidence_ref) + for item in result.limitations + ] == [ + ("evidence_after_cutoff_excluded", "email:late"), + ] + + +def test_explicit_rfc_reply_overrides_semantic_parent_and_remains_observed() -> None: + request = _request( + [ + _record( + "email:observed-parent", + "Unrelated root", + "2026-08-20T09:00:00Z", + ), + _record( + "email:semantic-parent", + "Phoenix status", + "2026-08-20T09:01:00Z", + ), + _record( + "email:child", + "Phoenix status follow-up", + "2026-08-20T09:02:00Z", + explicit_parent={ + "evidence_ref": "email:observed-parent", + "relation_code": "rfc_reply", + }, + ), + ] + ) + + result = analyze_external_lineage(request) + child_edges = [ + edge + for edge in result.edges + if edge.child_evidence_ref == "email:child" + ] + + assert len(child_edges) == 1 + assert child_edges[0].parent_evidence_ref == "email:observed-parent" + assert child_edges[0].relation_type_code == "rfc_reply" + assert child_edges[0].truth_status_code == "observed" + assert child_edges[0].channel_evidence[0].channel_code == "rfc_reply" + + +def test_inferred_edge_exposes_active_channel_weights_and_contributions() -> None: + request = _request( + [ + _record( + "email:001", + "Phoenix delivery status", + "2026-08-20T09:00:00Z", + ), + _record( + "email:002", + "Phoenix delivery status update", + "2026-08-20T09:05:00Z", + ), + ] + ) + + result = analyze_external_lineage(request) + + assert len(result.edges) == 1 + edge = result.edges[0] + assert edge.truth_status_code == "inferred" + assert edge.relation_type_code == "reconstructed_continuation" + assert {item.channel_code for item in edge.channel_evidence} == { + "temporal", + "secondary_key", + "text", + } + assert sum(item.weight for item in edge.channel_evidence) == pytest.approx( + 1.0 + ) + assert sum( + item.contribution + for item in edge.channel_evidence + ) == pytest.approx(edge.fused_score) + + +@pytest.mark.parametrize( + ("allow_llm", "client", "expected_status", "llm_present"), + [ + (False, AvailableLlm(), "not_requested", False), + (True, None, "unavailable", False), + (True, AvailableLlm(), "completed", True), + ], +) +def test_llm_policy_is_explicit_and_never_fabricates_absent_scores( + allow_llm: bool, + client, + expected_status: str, + llm_present: bool, +) -> None: + request = _request( + [ + _record( + "email:001", + "Phoenix delivery status", + "2026-08-20T09:00:00Z", + ), + _record( + "email:002", + "Phoenix delivery status update", + "2026-08-20T09:05:00Z", + ), + ], + allow_llm=allow_llm, + ) + + result = analyze_external_lineage(request, llm=client) + + assert result.llm_status_code == expected_status + channels = { + channel.channel_code + for channel in result.edges[0].channel_evidence + } + assert ("llm" in channels) is llm_present + + +def test_available_llm_is_not_claimed_when_no_inferred_pair_exists() -> None: + """A configured LLM is not reported as completed when no call was needed.""" + request = _request( + [_record("email:001", "Only record", "2026-08-20T09:00:00Z")], + allow_llm=True, + ) + + result = analyze_external_lineage(request, llm=AvailableLlm()) + + assert result.llm_status_code == "not_invoked" + assert result.edges == () + + +def test_project_projection_is_proposed_and_uses_only_included_evidence() -> None: + request = _request( + [ + _record( + "email:001", + "One", + "2026-08-20T09:00:00Z", + ), + _record( + "email:002", + "Two", + "2026-08-20T09:01:00Z", + ), + _record( + "email:003", + "Late", + "2026-08-18T09:00:00Z", + available_at="2026-08-22T09:00:00Z", + ), + ], + cutoff="2026-08-21T00:00:00Z", + scope="project_history", + ) + + result = analyze_external_lineage(request) + + assert result.project_projections[0].project_ref == "project:opaque" + assert result.project_projections[0].evidence_refs == ( + "email:001", + "email:002", + ) + assert result.project_projections[0].truth_status_code == "proposed" + + +def test_analysis_is_deterministic_for_reordered_input_and_has_digest() -> None: + records = [ + _record( + "email:001", + "Phoenix delivery status", + "2026-08-20T09:00:00Z", + ), + _record( + "email:002", + "Phoenix delivery status update", + "2026-08-20T09:05:00Z", + ), + ] + first_request = _request(records) + second_request = _request(list(reversed(records))) + + first = analyze_external_lineage(first_request) + second = analyze_external_lineage(second_request) + + assert request_digest(first_request) == request_digest(second_request) + assert first == second + assert first.result_digest.startswith("sha256:") + assert result_digest(first) == first.result_digest + + +@pytest.mark.parametrize( + ("records", "expected_code"), + [ + ( + [ + _record( + "email:child", + "Child", + "2026-08-20T09:00:00Z", + explicit_parent={ + "evidence_ref": "email:missing", + "relation_code": "rfc_reply", + }, + ) + ], + "explicit_parent_missing", + ), + ( + [ + _record( + "email:child", + "Child", + "2026-08-20T09:00:00Z", + explicit_parent={ + "evidence_ref": "email:child", + "relation_code": "rfc_reply", + }, + ) + ], + "explicit_parent_self_reference", + ), + ( + [ + _record( + "email:parent", + "Parent", + "2026-08-20T10:00:00Z", + ), + _record( + "email:child", + "Child", + "2026-08-20T09:00:00Z", + explicit_parent={ + "evidence_ref": "email:parent", + "relation_code": "rfc_reply", + }, + ), + ], + "explicit_parent_after_child", + ), + ( + [ + _record( + "email:parent", + "Parent", + "2026-08-20T09:00:00Z", + group_ref="workspace:one", + ), + _record( + "email:child", + "Child", + "2026-08-20T10:00:00Z", + group_ref="workspace:two", + explicit_parent={ + "evidence_ref": "email:parent", + "relation_code": "rfc_reply", + }, + ), + ], + "explicit_parent_group_mismatch", + ), + ], +) +def test_invalid_explicit_parent_semantics_fail_closed( + records: list[dict[str, object]], + expected_code: str, +) -> None: + request = _request(records) + + with pytest.raises(LineageContractError) as captured: + analyze_external_lineage(request) + + assert captured.value.code == expected_code + + +def test_explicit_parent_cycle_fails_closed_even_when_timestamps_tie() -> None: + request = _request( + [ + _record( + "email:one", + "One", + "2026-08-20T09:00:00Z", + explicit_parent={ + "evidence_ref": "email:two", + "relation_code": "rfc_reply", + }, + ), + _record( + "email:two", + "Two", + "2026-08-20T09:00:00Z", + explicit_parent={ + "evidence_ref": "email:one", + "relation_code": "rfc_reply", + }, + ), + ] + ) + + with pytest.raises(LineageContractError) as captured: + analyze_external_lineage(request) + + assert captured.value.code == "explicit_parent_cycle" + + +def test_cutoff_excluded_explicit_parent_creates_limitation_not_edge() -> None: + request = _request( + [ + _record( + "email:parent", + "Parent", + "2026-08-18T09:00:00Z", + available_at="2026-08-22T09:00:00Z", + ), + _record( + "email:child", + "Child", + "2026-08-20T09:00:00Z", + available_at="2026-08-20T09:01:00Z", + explicit_parent={ + "evidence_ref": "email:parent", + "relation_code": "rfc_reply", + }, + ), + ], + cutoff="2026-08-21T00:00:00Z", + ) + + result = analyze_external_lineage(request) + + assert all( + edge.relation_type_code != "rfc_reply" + for edge in result.edges + ) + assert any( + item.limitation_code == "explicit_parent_after_cutoff" + and item.evidence_ref == "email:child" + for item in result.limitations + ) + + +def test_all_evidence_after_cutoff_returns_empty_bounded_result() -> None: + request = _request( + [ + _record( + "email:late", + "Late", + "2026-08-18T09:00:00Z", + available_at="2026-08-22T09:00:00Z", + project_ref=None, + ) + ], + cutoff="2026-08-21T00:00:00Z", + ) + + result = analyze_external_lineage(request) + + assert result.included_evidence_refs == () + assert result.edges == () + assert result.project_projections == () + + +def test_invalid_llm_score_fails_closed_before_result_projection() -> None: + request = _request( + [ + _record( + "email:001", + "Phoenix one", + "2026-08-20T09:00:00Z", + ), + _record( + "email:002", + "Phoenix two", + "2026-08-20T09:01:00Z", + ), + ], + allow_llm=True, + ) + + with pytest.raises(LineageContractError) as captured: + analyze_external_lineage(request, llm=InvalidLlm()) + + assert captured.value.code == "channel_score_out_of_bounds" + + +def test_non_numeric_llm_score_fails_closed_at_the_contract_boundary() -> None: + """A provider score with the wrong type becomes a stable contract error.""" + + request = _request( + [ + _record("email:001", "Phoenix one", "2026-08-20T09:00:00Z"), + _record("email:002", "Phoenix two", "2026-08-20T09:01:00Z"), + ], + allow_llm=True, + ) + + with pytest.raises(LineageContractError) as captured: + analyze_external_lineage(request, llm=TextLlm()) + + assert captured.value.code == "channel_score_out_of_bounds" + + +def test_raw_provider_response_error_is_stable_at_the_contract_boundary() -> None: + """A raw provider failure is not exposed as an arbitrary exception.""" + + request = _request( + [ + _record("email:001", "Phoenix one", "2026-08-20T09:00:00Z"), + _record("email:002", "Phoenix two", "2026-08-20T09:01:00Z"), + ], + allow_llm=True, + ) + + with pytest.raises(LineageContractError) as captured: + analyze_external_lineage(request, llm=BrokenProviderLlm()) + + assert captured.value.code == "llm_channel_error" + assert "provider secret" not in str(captured.value) + + +def test_channel_evidence_rejects_invalid_score_before_serialization() -> None: + """Defense in depth keeps direct channel projection fail-closed.""" + + with pytest.raises(LineageContractError) as captured: + _channel_evidence({"text": 2.0}, {"text": 1.0}) + + assert captured.value.code == "channel_score_out_of_bounds" + + +def test_records_without_project_reference_are_not_projected() -> None: + request = _request( + [ + _record( + "email:001", + "No project", + "2026-08-20T09:00:00Z", + project_ref=None, + ) + ] + ) + + result = analyze_external_lineage(request) + + assert result.project_projections == () + + +def test_cutoff_excluded_explicit_parent_suppresses_alternative_inference() -> None: + request = _request( + [ + _record( + "email:alternative", + "Phoenix child", + "2026-08-20T08:00:00Z", + available_at="2026-08-20T08:01:00Z", + ), + _record( + "email:observed-parent", + "Observed parent", + "2026-08-18T09:00:00Z", + available_at="2026-08-22T09:00:00Z", + ), + _record( + "email:child", + "Phoenix child", + "2026-08-20T09:00:00Z", + available_at="2026-08-20T09:01:00Z", + explicit_parent={ + "evidence_ref": "email:observed-parent", + "relation_code": "rfc_reply", + }, + ), + ], + cutoff="2026-08-21T00:00:00Z", + ) + + result = analyze_external_lineage(request) + + assert all( + edge.child_evidence_ref != "email:child" + for edge in result.edges + ) + + +def test_project_projections_do_not_merge_across_groups() -> None: + request = _request( + [ + _record( + "email:one", + "One", + "2026-08-20T09:00:00Z", + group_ref="workspace:one", + ), + _record( + "email:two", + "Two", + "2026-08-20T09:00:00Z", + group_ref="workspace:two", + ), + ], + scope="project_history", + ) + + result = analyze_external_lineage(request) + projections = [ + (item.group_ref, item.project_ref, item.evidence_refs) + for item in result.project_projections + ] + + assert projections == [ + ("workspace:one", "project:opaque", ("email:one",)), + ("workspace:two", "project:opaque", ("email:two",)), + ] + + +def test_pair_budget_rejects_before_any_optional_llm_call() -> None: + records = [ + _record( + f"email:{index}", + f"Message {index}", + f"2026-08-20T09:0{index}:00Z", + ) + for index in range(4) + ] + payload = { + "contract_version": "1.0.0", + "analysis_id": "analysis:pair-budget", + "analysis_scope_code": "email_lineage", + "knowledge_cutoff": None, + "policy": { + "candidate_window": 50, + "maximum_pair_evaluations": 2, + "minimum_fused_score": 0.1, + "allow_llm": True, + }, + "records": records, + } + request = parse_lineage_analysis_request(payload) + client = CountingLlm() + + with pytest.raises(LineageContractError) as captured: + analyze_external_lineage(request, llm=client) + + assert captured.value.code == "pair_evaluation_budget_exceeded" + assert client.call_count == 0 + + +def test_missing_cutoff_includes_all_records() -> None: + request = _request( + [ + _record( + "email:one", + "One", + "2026-08-20T09:00:00Z", + ), + _record( + "email:two", + "Two", + "2026-08-21T09:00:00Z", + available_at="2026-09-01T09:00:00Z", + ), + ], + cutoff=None, + ) + + result = analyze_external_lineage(request) + + assert result.included_evidence_refs == ("email:one", "email:two") + assert result.excluded_evidence_refs == () diff --git a/tests/test_external_lineage_contract.py b/tests/test_external_lineage_contract.py new file mode 100644 index 000000000..db4a36275 --- /dev/null +++ b/tests/test_external_lineage_contract.py @@ -0,0 +1,718 @@ +"""Contract tests for the future Naruon-facing LineageWeave boundary.""" + +from __future__ import annotations + +import json +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from lineageweave.external_lineage_contract import ( + CONTRACT_VERSION, + ChannelEvidence, + ExplicitParent, + LineageAnalysisResult, + LineageContractError, + LineageEdgeResult, + LineageLimitation, + ProjectProjection, + parse_lineage_analysis_request, + request_digest, + result_digest, + serialize_lineage_analysis_request, + serialize_lineage_analysis_result, +) + +_ROOT = Path(__file__).resolve().parents[1] + + +def _record( + evidence_ref: str, + *, + occurred_at: str = "2026-08-20T09:00:00Z", + available_at: str = "2026-08-20T09:01:00Z", + explicit_parent: dict[str, str] | None = None, +) -> dict[str, object]: + return { + "evidence_ref": evidence_ref, + "group_ref": "workspace:demo", + "source_kind_code": "email", + "truth_status_code": "observed", + "label": f"Subject {evidence_ref}", + "occurred_at": occurred_at, + "available_at": available_at, + "secondary_key": "provider-thread:opaque", + "project_ref": "project:opaque", + "explicit_parent": explicit_parent, + } + + +def _payload() -> dict[str, object]: + return { + "contract_version": "1.0.0", + "analysis_id": "analysis:demo-001", + "analysis_scope_code": "email_lineage", + "knowledge_cutoff": "2026-08-20T18:00:00+09:00", + "policy": { + "candidate_window": 50, + "maximum_pair_evaluations": 1000, + "minimum_fused_score": 0.3, + "allow_llm": False, + }, + "records": [ + _record("email:001"), + _record( + "email:002", + occurred_at="2026-08-20T09:05:00Z", + available_at="2026-08-20T09:06:00Z", + explicit_parent={ + "evidence_ref": "email:001", + "relation_code": "rfc_reply", + }, + ), + ], + } + + +def _result_fixture() -> LineageAnalysisResult: + return LineageAnalysisResult( + contract_version=CONTRACT_VERSION, + analysis_id="analysis:fixture", + analysis_scope_code="generic_lineage", + knowledge_cutoff=None, + included_evidence_refs=("record:001",), + excluded_evidence_refs=(), + llm_status_code="not_requested", + edges=(), + project_projections=(), + limitations=(), + result_digest="", + ) + + +def test_parse_request_is_strict_immutable_and_canonicalizes_timestamps() -> None: + request = parse_lineage_analysis_request(_payload()) + + assert request.contract_version == CONTRACT_VERSION + assert request.analysis_id == "analysis:demo-001" + assert request.analysis_scope_code == "email_lineage" + assert request.knowledge_cutoff == datetime( + 2026, + 8, + 20, + 9, + 0, + tzinfo=timezone.utc, + ) + assert request.records[1].explicit_parent == ExplicitParent( + evidence_ref="email:001", + relation_code="rfc_reply", + ) + assert serialize_lineage_analysis_request(request)[ + "knowledge_cutoff" + ] == "2026-08-20T09:00:00Z" + with pytest.raises(AttributeError): + request.analysis_id = "changed" # type: ignore[misc] + + +def test_request_digest_is_stable_when_keys_and_records_are_reordered() -> None: + payload = _payload() + reordered = { + "records": list(reversed(payload["records"])), # type: ignore[arg-type] + "policy": { + "allow_llm": False, + "minimum_fused_score": 0.3, + "maximum_pair_evaluations": 1000, + "candidate_window": 50, + }, + "knowledge_cutoff": payload["knowledge_cutoff"], + "analysis_scope_code": payload["analysis_scope_code"], + "analysis_id": payload["analysis_id"], + "contract_version": payload["contract_version"], + } + + assert request_digest( + parse_lineage_analysis_request(payload) + ) == request_digest(parse_lineage_analysis_request(reordered)) + + +@pytest.mark.parametrize( + ("mutator", "expected_code"), + [ + (lambda payload: payload.update({"unexpected": True}), "unknown_field"), + ( + lambda payload: payload["policy"].update( # type: ignore[union-attr] + {"unexpected": True} + ), + "unknown_field", + ), + ( + lambda payload: payload["records"][0].update( # type: ignore[index,union-attr] + {"unexpected": True} + ), + "unknown_field", + ), + ( + lambda payload: payload.update({"contract_version": "2.0.0"}), + "unsupported_contract_version", + ), + ( + lambda payload: payload.update( + {"analysis_scope_code": "mailbox_dump"} + ), + "unknown_analysis_scope", + ), + ], +) +def test_parser_rejects_unknown_fields_and_vocabularies( + mutator, + expected_code: str, +) -> None: + payload = _payload() + mutator(payload) + + with pytest.raises(LineageContractError) as captured: + parse_lineage_analysis_request(payload) + + assert captured.value.code == expected_code + + +def test_parser_rejects_duplicate_references_and_record_count_bounds() -> None: + payload = _payload() + payload["records"] = [_record("email:001"), _record("email:001")] + with pytest.raises(LineageContractError) as duplicate: + parse_lineage_analysis_request(payload) + assert duplicate.value.code == "duplicate_evidence_ref" + + payload["records"] = [] + with pytest.raises(LineageContractError) as empty: + parse_lineage_analysis_request(payload) + assert empty.value.code == "record_count_out_of_bounds" + + payload["records"] = [ + _record(f"email:{index:03d}") + for index in range(501) + ] + with pytest.raises(LineageContractError) as oversized: + parse_lineage_analysis_request(payload) + assert oversized.value.code == "record_count_out_of_bounds" + + +@pytest.mark.parametrize( + ("field_name", "value", "expected_code"), + [ + ( + "occurred_at", + "2026-08-20T09:00:00", + "timestamp_must_be_offset_aware", + ), + ("available_at", "not-a-time", "invalid_timestamp"), + ( + "evidence_ref", + "https://mail.example/message/1", + "unsafe_opaque_reference", + ), + ("evidence_ref", "contains whitespace", "unsafe_opaque_reference"), + ("label", "", "text_length_out_of_bounds"), + ("label", "x" * 2001, "text_length_out_of_bounds"), + ], +) +def test_parser_rejects_unsafe_identifiers_timestamps_and_text( + field_name: str, + value: str, + expected_code: str, +) -> None: + payload = _payload() + payload["records"][0][field_name] = value # type: ignore[index] + + with pytest.raises(LineageContractError) as captured: + parse_lineage_analysis_request(payload) + + assert captured.value.code == expected_code + + +@pytest.mark.parametrize( + ("field_name", "value", "expected_code"), + [ + ("candidate_window", 0, "policy_value_out_of_bounds"), + ("candidate_window", 201, "policy_value_out_of_bounds"), + ("maximum_pair_evaluations", 0, "policy_value_out_of_bounds"), + ("maximum_pair_evaluations", 5_001, "policy_value_out_of_bounds"), + ("minimum_fused_score", -0.1, "policy_value_out_of_bounds"), + ("minimum_fused_score", 1.1, "policy_value_out_of_bounds"), + ("allow_llm", "yes", "invalid_field_type"), + ], +) +def test_parser_rejects_invalid_policy_values( + field_name: str, + value: object, + expected_code: str, +) -> None: + payload = _payload() + payload["policy"][field_name] = value # type: ignore[index] + + with pytest.raises(LineageContractError) as captured: + parse_lineage_analysis_request(payload) + + assert captured.value.code == expected_code + + +def test_result_serialization_is_deterministic_and_digest_is_external() -> None: + edge = LineageEdgeResult( + parent_evidence_ref="email:001", + child_evidence_ref="email:002", + relation_type_code="reconstructed_continuation", + truth_status_code="inferred", + fused_score=0.75, + channel_evidence=( + ChannelEvidence("text", 0.8, 0.5, 0.4), + ChannelEvidence("temporal", 0.7, 0.5, 0.35), + ), + ) + result = LineageAnalysisResult( + contract_version=CONTRACT_VERSION, + analysis_id="analysis:demo-001", + analysis_scope_code="email_lineage", + knowledge_cutoff=datetime( + 2026, + 8, + 20, + 9, + 0, + tzinfo=timezone.utc, + ), + included_evidence_refs=("email:001", "email:002"), + excluded_evidence_refs=(), + llm_status_code="not_requested", + edges=(edge,), + project_projections=( + ProjectProjection( + "workspace:demo", + "project:opaque", + ("email:001", "email:002"), + "proposed", + ), + ), + limitations=( + LineageLimitation("none", None, "No material limitation."), + ), + result_digest="", + ) + digest = result_digest(result) + finalized = replace(result, result_digest=digest) + + serialized = serialize_lineage_analysis_result(finalized) + assert serialized["result_digest"] == digest + assert serialized["knowledge_cutoff"] == "2026-08-20T09:00:00Z" + assert result_digest(finalized) == digest + assert json.dumps(serialized, sort_keys=True, separators=(",", ":")) + + +def test_public_schema_exists_and_mirrors_contract_vocabularies() -> None: + schema = json.loads( + ( + _ROOT + / "docs" + / "contracts" + / "external-lineage-analysis-v1.schema.json" + ).read_text(encoding="utf-8") + ) + + assert schema["$schema"] == ( + "https://json-schema.org/draft/2020-12/schema" + ) + assert schema["properties"]["contract_version"]["const"] == ( + CONTRACT_VERSION + ) + assert set( + schema["properties"]["analysis_scope_code"]["enum"] + ) == { + "email_lineage", + "project_history", + "generic_lineage", + } + assert schema["additionalProperties"] is False + assert set( + schema["$defs"]["LineageAnalysisResult"]["properties"]["llm_status_code"]["enum"] + ) == {"not_requested", "unavailable", "not_invoked", "completed"} + pair_budget = schema["$defs"]["LineageAnalysisPolicy"][ + "properties" + ]["maximum_pair_evaluations"] + assert pair_budget == { + "type": "integer", + "minimum": 1, + "maximum": 5000, + } + + +def test_parser_rejects_non_object_and_missing_required_field() -> None: + with pytest.raises(LineageContractError) as non_object: + parse_lineage_analysis_request([]) + assert non_object.value.code == "invalid_field_type" + + payload = _payload() + del payload["analysis_id"] + with pytest.raises(LineageContractError) as missing: + parse_lineage_analysis_request(payload) + assert missing.value.code == "missing_field" + + +def test_parser_rejects_wrong_scalar_types_and_non_array_records() -> None: + mutations = [ + ("contract_version", 1, "invalid_field_type"), + ("knowledge_cutoff", 1, "invalid_field_type"), + ("analysis_scope_code", 1, "invalid_field_type"), + ] + for field, value, expected in mutations: + payload = _payload() + payload[field] = value + with pytest.raises(LineageContractError) as captured: + parse_lineage_analysis_request(payload) + assert captured.value.code == expected + + payload = _payload() + payload["policy"]["minimum_fused_score"] = "0.3" # type: ignore[index] + with pytest.raises(LineageContractError) as number: + parse_lineage_analysis_request(payload) + assert number.value.code == "invalid_field_type" + + payload = _payload() + payload["policy"]["candidate_window"] = 50.0 # type: ignore[index] + with pytest.raises(LineageContractError) as integer: + parse_lineage_analysis_request(payload) + assert integer.value.code == "invalid_field_type" + + payload = _payload() + payload["records"] = tuple(payload["records"]) # type: ignore[arg-type] + with pytest.raises(LineageContractError) as records: + parse_lineage_analysis_request(payload) + assert records.value.code == "invalid_field_type" + + +def test_optional_references_may_be_omitted() -> None: + payload = _payload() + record = payload["records"][0] # type: ignore[index] + del record["secondary_key"] + del record["project_ref"] + del record["explicit_parent"] + + parsed = parse_lineage_analysis_request(payload) + + assert parsed.records[0].secondary_key is None + assert parsed.records[0].project_ref is None + assert parsed.records[0].explicit_parent is None + + +def test_result_serializer_rejects_naive_timestamp_and_invalid_scores() -> None: + result = replace( + _result_fixture(), + knowledge_cutoff=datetime(2026, 8, 20, 9, 0), + ) + with pytest.raises(LineageContractError) as naive: + serialize_lineage_analysis_result(result) + assert naive.value.code == "timestamp_must_be_offset_aware" + + invalid_type_edge = LineageEdgeResult( + "record:001", + "record:002", + "reconstructed_continuation", + "inferred", + True, # type: ignore[arg-type] + (ChannelEvidence("text", 0.5, 1.0, 0.5),), + ) + result_with_two_records = replace( + _result_fixture(), + included_evidence_refs=("record:001", "record:002"), + ) + with pytest.raises(LineageContractError) as score_type: + serialize_lineage_analysis_result( + replace( + result_with_two_records, + edges=(invalid_type_edge,), + ) + ) + assert score_type.value.code == "invalid_field_type" + + invalid_range_edge = replace(invalid_type_edge, fused_score=1.1) + with pytest.raises(LineageContractError) as score_range: + serialize_lineage_analysis_result( + replace( + result_with_two_records, + edges=(invalid_range_edge,), + ) + ) + assert score_range.value.code == "score_out_of_bounds" + + +def test_result_serializer_rejects_non_proposed_project_and_wrong_version() -> None: + project = ProjectProjection( + "workspace:one", + "project:one", + ("record:001",), + "observed", + ) # type: ignore[arg-type] + with pytest.raises(LineageContractError) as truth: + serialize_lineage_analysis_result( + replace( + _result_fixture(), + project_projections=(project,), + ) + ) + assert truth.value.code == "unknown_result_truth_status" + + with pytest.raises(LineageContractError) as version: + serialize_lineage_analysis_result( + replace(_result_fixture(), contract_version="2.0.0") + ) + assert version.value.code == "unsupported_contract_version" + + +def test_result_requires_a_valid_digest_for_transport() -> None: + with pytest.raises(LineageContractError) as captured: + serialize_lineage_analysis_result(_result_fixture()) + + assert captured.value.code == "invalid_result_digest" + + +def test_result_rejects_overlapping_or_duplicate_partitions() -> None: + overlap = replace( + _result_fixture(), + included_evidence_refs=("record:001",), + excluded_evidence_refs=("record:001",), + result_digest="sha256:" + "0" * 64, + ) + with pytest.raises(LineageContractError) as captured: + serialize_lineage_analysis_result(overlap) + assert captured.value.code == "evidence_partition_overlap" + + duplicate = replace( + _result_fixture(), + included_evidence_refs=("record:001", "record:001"), + result_digest="sha256:" + "0" * 64, + ) + with pytest.raises(LineageContractError) as duplicate_error: + serialize_lineage_analysis_result(duplicate) + assert duplicate_error.value.code == "duplicate_evidence_ref" + + +def test_result_rejects_unincluded_edge_or_project_references() -> None: + edge = LineageEdgeResult( + "record:001", + "record:missing", + "reconstructed_continuation", + "inferred", + 0.5, + (ChannelEvidence("text", 0.5, 1.0, 0.5),), + ) + result = replace( + _result_fixture(), + edges=(edge,), + result_digest="sha256:" + "0" * 64, + ) + with pytest.raises(LineageContractError) as edge_error: + serialize_lineage_analysis_result(result) + assert edge_error.value.code == "edge_reference_not_included" + + project = ProjectProjection( + "workspace:one", + "project:one", + ("record:missing",), + "proposed", + ) + result = replace( + _result_fixture(), + project_projections=(project,), + result_digest="sha256:" + "0" * 64, + ) + with pytest.raises(LineageContractError) as project_error: + serialize_lineage_analysis_result(result) + assert project_error.value.code == "project_reference_not_included" + + +def test_result_rejects_self_edges_and_channel_math_errors() -> None: + base = replace( + _result_fixture(), + included_evidence_refs=("record:001", "record:002"), + ) + self_edge = LineageEdgeResult( + "record:001", + "record:001", + "reconstructed_continuation", + "inferred", + 0.5, + (ChannelEvidence("text", 0.5, 1.0, 0.5),), + ) + with pytest.raises(LineageContractError) as self_error: + serialize_lineage_analysis_result( + replace( + base, + edges=(self_edge,), + result_digest="sha256:" + "0" * 64, + ) + ) + assert self_error.value.code == "self_lineage_edge" + + duplicate_channels = replace( + self_edge, + parent_evidence_ref="record:002", + channel_evidence=( + ChannelEvidence("text", 0.5, 0.5, 0.25), + ChannelEvidence("text", 0.5, 0.5, 0.25), + ), + ) + with pytest.raises(LineageContractError) as duplicate_error: + serialize_lineage_analysis_result( + replace( + base, + edges=(duplicate_channels,), + result_digest="sha256:" + "0" * 64, + ) + ) + assert duplicate_error.value.code == "duplicate_channel_code" + + bad_weights = replace( + duplicate_channels, + channel_evidence=( + ChannelEvidence("text", 0.5, 0.4, 0.2), + ChannelEvidence("temporal", 0.5, 0.4, 0.2), + ), + ) + with pytest.raises(LineageContractError) as weight_error: + serialize_lineage_analysis_result( + replace( + base, + edges=(bad_weights,), + result_digest="sha256:" + "0" * 64, + ) + ) + assert weight_error.value.code == "channel_weight_sum_mismatch" + + bad_contribution = replace( + bad_weights, + channel_evidence=( + ChannelEvidence("text", 0.5, 0.5, 0.2), + ChannelEvidence("temporal", 0.5, 0.5, 0.2), + ), + ) + with pytest.raises(LineageContractError) as contribution_error: + serialize_lineage_analysis_result( + replace( + base, + edges=(bad_contribution,), + result_digest="sha256:" + "0" * 64, + ) + ) + assert contribution_error.value.code == ( + "channel_contribution_mismatch" + ) + + +def test_result_rejects_unsafe_analysis_identifier() -> None: + result = replace( + _result_fixture(), + analysis_id="https://unsafe.example/run", + result_digest="sha256:" + "0" * 64, + ) + with pytest.raises(LineageContractError) as captured: + serialize_lineage_analysis_result(result) + assert captured.value.code == "unsafe_opaque_reference" + + +def test_result_rejects_missing_channels_and_contribution_mismatch() -> None: + base = replace( + _result_fixture(), + included_evidence_refs=("record:001", "record:002"), + ) + missing_channels = LineageEdgeResult( + "record:001", + "record:002", + "reconstructed_continuation", + "inferred", + 0.5, + (), + ) + with pytest.raises(LineageContractError) as missing: + serialize_lineage_analysis_result( + replace( + base, + edges=(missing_channels,), + result_digest="sha256:" + "0" * 64, + ) + ) + assert missing.value.code == "missing_channel_evidence" + + inconsistent = replace( + missing_channels, + channel_evidence=( + ChannelEvidence("text", 0.5, 0.5, 0.3), + ChannelEvidence("temporal", 0.5, 0.5, 0.2), + ), + ) + with pytest.raises(LineageContractError) as mismatch: + serialize_lineage_analysis_result( + replace( + base, + edges=(inconsistent,), + result_digest="sha256:" + "0" * 64, + ) + ) + assert mismatch.value.code == "channel_contribution_mismatch" + + +def test_result_rejects_channel_sum_that_does_not_equal_fused_score() -> None: + """The fused score must reconcile with all otherwise valid contributions.""" + + edge = LineageEdgeResult( + "record:001", + "record:002", + "reconstructed_continuation", + "inferred", + 0.5, + ( + ChannelEvidence("text", 0.2, 0.5, 0.1), + ChannelEvidence("temporal", 0.2, 0.5, 0.1), + ), + ) + with pytest.raises(LineageContractError) as captured: + serialize_lineage_analysis_result( + replace( + _result_fixture(), + included_evidence_refs=("record:001", "record:002"), + edges=(edge,), + result_digest="sha256:" + "0" * 64, + ) + ) + + assert captured.value.code == "channel_contribution_mismatch" + + +def test_result_rejects_duplicate_project_evidence_references() -> None: + project = ProjectProjection( + "workspace:one", + "project:one", + ("record:001", "record:001"), + "proposed", + ) + with pytest.raises(LineageContractError) as captured: + serialize_lineage_analysis_result( + replace( + _result_fixture(), + project_projections=(project,), + result_digest="sha256:" + "0" * 64, + ) + ) + assert captured.value.code == "duplicate_evidence_ref" + + +def test_result_rejects_digest_not_matching_canonical_content() -> None: + result = replace( + _result_fixture(), + result_digest="sha256:" + "0" * 64, + ) + + with pytest.raises(LineageContractError) as captured: + serialize_lineage_analysis_result(result) + + assert captured.value.code == "result_digest_mismatch" diff --git a/tests/test_external_lineage_explicit_parent_budget.py b/tests/test_external_lineage_explicit_parent_budget.py new file mode 100644 index 000000000..3455278f8 --- /dev/null +++ b/tests/test_external_lineage_explicit_parent_budget.py @@ -0,0 +1,157 @@ +"""Regression tests for explicit-parent budget and provider minimization.""" + +from __future__ import annotations + +from lineageweave.external_lineage_analysis import analyze_external_lineage +from lineageweave.external_lineage_contract import parse_lineage_analysis_request + + +class CountingLlm: + """Available adjudication client that records every disclosed label pair.""" + + available = True + + def __init__(self) -> None: + """Initialize an empty provider-call ledger.""" + + self.calls: list[tuple[str, str]] = [] + + def judge(self, candidate_label: str, record_label: str) -> float: + """Record one adjudication pair and return a bounded score.""" + + self.calls.append((candidate_label, record_label)) + return 0.5 + + +def _record( + evidence_ref: str, + label: str, + occurred_at: str, + *, + explicit_parent: str | None = None, +) -> dict[str, object]: + """Build one synthetic authorized email evidence record.""" + + return { + "evidence_ref": evidence_ref, + "group_ref": "workspace:synthetic", + "source_kind_code": "email", + "truth_status_code": "observed", + "label": label, + "occurred_at": occurred_at, + "available_at": occurred_at, + "secondary_key": "thread:synthetic", + "project_ref": "project:synthetic", + "explicit_parent": ( + { + "evidence_ref": explicit_parent, + "relation_code": "rfc_reply", + } + if explicit_parent is not None + else None + ), + } + + +def _request( + records: list[dict[str, object]], + *, + allow_llm: bool, + maximum_pair_evaluations: int, +): + """Parse one strict external-lineage request for the regression cases.""" + + return parse_lineage_analysis_request( + { + "contract_version": "1.0.0", + "analysis_id": "analysis:explicit-parent-budget", + "analysis_scope_code": "email_lineage", + "knowledge_cutoff": None, + "policy": { + "candidate_window": 50, + "maximum_pair_evaluations": maximum_pair_evaluations, + "minimum_fused_score": 0.1, + "allow_llm": allow_llm, + }, + "records": records, + } + ) + + +def test_explicit_parent_chain_spends_no_inference_budget_or_llm_calls() -> None: + """Caller-observed edges must not be rescored or charged as inferred work.""" + + request = _request( + [ + _record("email:one", "One", "2026-08-21T09:00:00Z"), + _record( + "email:two", + "Two", + "2026-08-21T09:01:00Z", + explicit_parent="email:one", + ), + _record( + "email:three", + "Three", + "2026-08-21T09:02:00Z", + explicit_parent="email:two", + ), + _record( + "email:four", + "Four", + "2026-08-21T09:03:00Z", + explicit_parent="email:three", + ), + ], + allow_llm=True, + maximum_pair_evaluations=1, + ) + client = CountingLlm() + + result = analyze_external_lineage(request, llm=client) + + assert client.calls == [] + assert [ + ( + edge.parent_evidence_ref, + edge.child_evidence_ref, + edge.truth_status_code, + ) + for edge in result.edges + ] == [ + ("email:one", "email:two", "observed"), + ("email:two", "email:three", "observed"), + ("email:three", "email:four", "observed"), + ] + + +def test_explicit_child_remains_available_as_a_later_inference_candidate() -> None: + """Skipping its own scoring must not remove an explicit child from history.""" + + request = _request( + [ + _record("email:root", "Root", "2026-08-21T09:00:00Z"), + _record( + "email:observed-child", + "Phoenix delivery update", + "2026-08-21T09:01:00Z", + explicit_parent="email:root", + ), + _record( + "email:later-child", + "Phoenix delivery update", + "2026-08-21T09:02:00Z", + ), + ], + allow_llm=False, + maximum_pair_evaluations=2, + ) + + result = analyze_external_lineage(request) + + assert any( + edge.parent_evidence_ref == "email:observed-child" + and edge.child_evidence_ref == "email:later-child" + and edge.truth_status_code == "inferred" + for edge in result.edges + ) diff --git a/tests/test_external_lineage_public_api.py b/tests/test_external_lineage_public_api.py new file mode 100644 index 000000000..9d45f5791 --- /dev/null +++ b/tests/test_external_lineage_public_api.py @@ -0,0 +1,16 @@ +"""Public import-surface tests for external lineage consumers.""" + +from __future__ import annotations + +import lineageweave.external_lineage as external_lineage + + +def test_external_lineage_module_exports_the_versioned_contract() -> None: + assert external_lineage.CONTRACT_VERSION == "1.0.0" + assert callable(external_lineage.parse_lineage_analysis_request) + assert callable(external_lineage.analyze_external_lineage) + assert callable(external_lineage.request_digest) + assert callable(external_lineage.result_digest) + assert external_lineage.LineageContractError.__name__ == ( + "LineageContractError" + ) diff --git a/tests/test_image_content.py b/tests/test_image_content.py index de7fc49b5..148a979c5 100644 --- a/tests/test_image_content.py +++ b/tests/test_image_content.py @@ -85,6 +85,35 @@ def test_parse_description_preserves_multiline_ocr_text() -> None: assert description.caption == "A scanned page." +def test_parse_description_preserves_multiline_caption_evidence() -> None: + """Detailed VISION captions remain complete when providers wrap lines.""" + content = ( + "CAPTION: A project status table for the customer meeting.\n" + "The left column lists workstreams and the right column lists owners.\n" + "TEXT: Workstream | Owner\nAlpha | Team A\nTAGS: table, assignment" + ) + + description = _parse_description(content) + + assert description.caption == ( + "A project status table for the customer meeting.\n" + "The left column lists workstreams and the right column lists owners." + ) + assert description.extracted_text == "Workstream | Owner\nAlpha | Team A" + + +def test_parse_description_preserves_ocr_lines_that_contain_colons() -> None: + """A colon in a scanned field is OCR content, not a new response field.""" + content = ( + "TEXT: Invoice\nDate: 2026-08-21\nTotal: 100\n" + "CAPTION: A synthetic invoice.\nTAGS: invoice" + ) + + description = _parse_description(content) + + assert description.extracted_text == "Invoice\nDate: 2026-08-21\nTotal: 100" + + def test_parse_description_preserves_table_row_structure_in_ocr_text() -> None: """Live gap (2026-08-19): an image containing a table used to have its text flattened into an unstructured word list on OCR, the same @@ -202,6 +231,14 @@ def test_orchestrator_vision_client_does_not_double_v1() -> None: assert client._base_url == "https://gateway.example/v1" +def test_orchestrator_vision_client_allows_deep_agent_runtime() -> None: + """A valid VISION result must not be cut off by the former 180s limit.""" + client = orchestrator_vision_client("https://gateway.example", "key") + + assert isinstance(client, OpenAiCompatibleVisionClient) + assert client._timeout == 600.0 + + def test_orchestrator_vision_client_is_null_when_unconfigured() -> None: client = orchestrator_vision_client("", "") assert isinstance(client, NullImageContentClient) @@ -226,6 +263,17 @@ def test_ocr_prompt_asks_for_table_row_structure() -> None: assert "table" in _RESPONSE_FORMAT.lower() +def test_ocr_prompt_allows_multiline_tables_and_requests_semantic_detail() -> None: + """Table rows and ontology-ready captions must fit the response contract.""" + prompt = _RESPONSE_FORMAT.lower() + + assert "text may span multiple lines" in prompt + assert "separator row" in prompt + assert "named entities" in prompt + assert "relationships" in prompt + assert "exactly three lines" not in prompt + + def test_region_prompt_requires_full_image_coverage() -> None: """Live gap (2026-08-19): "distinct meaningful visual regions" alone let the model describe only the most visually striking part of an diff --git a/tests/test_lineage_contract.py b/tests/test_lineage_contract.py new file mode 100644 index 000000000..5fe3a4fc6 --- /dev/null +++ b/tests/test_lineage_contract.py @@ -0,0 +1,376 @@ +"""Synthetic contract tests for the reusable Naruon provider boundary.""" + +import json +from datetime import UTC, datetime, timedelta + +import pytest + +import lineageweave.lineage_contract as contract +from lineageweave.adjudication_client import NullAdjudicationClient +from lineageweave.lineage_contract import ( + EmailEvidence, + LineageAnalysisPolicy, + LineageAnalysisRequest, + LineageEvidenceRecord, + LineageProjectHint, + analyze_lineage, +) +from lineageweave.models import Edge, Record, Tree + +BASE_TIME = datetime(2026, 1, 1, 9, tzinfo=UTC) + + +class RawProviderFailure: + """Available provider client that exposes a secret-bearing failure.""" + + available = True + + def judge(self, candidate_label: str, record_label: str) -> float: + """Raise the raw provider failure that the contract must sanitize.""" + raise RuntimeError("provider secret response body") + + +class InvalidProviderScore: + """Available provider client that returns one invalid score value.""" + + available = True + + def __init__(self, score: object) -> None: + """Store the hostile score for the contract test.""" + self.score = score + + def judge(self, candidate_label: str, record_label: str) -> object: + """Return the hostile score without raising a transport error.""" + return self.score + + +class ValidProvider: + """Available provider client that returns a bounded confidence.""" + + available = True + + def judge(self, candidate_label: str, record_label: str) -> float: + """Return a valid synthetic confidence for the happy path.""" + return 0.7 + + +def evidence( + ref: str, + *, + available_at: datetime = BASE_TIME, + occurred_at: datetime = BASE_TIME, + label: str = "HVDC design review", +) -> LineageEvidenceRecord: + """Create a bounded synthetic record without real organization data.""" + return LineageEvidenceRecord( + evidence_ref=ref, + group_key="synthetic-customer", + label=label, + occurred_at=occurred_at, + available_at=available_at, + secondary_key="project-01", + body_text="Confirm drawing revision and delivery date.", + ) + + +def request(records: tuple[LineageEvidenceRecord, ...], **kwargs) -> LineageAnalysisRequest: + """Build a valid synthetic request with explicit authorization scope.""" + values = { + "analysis_id": "analysis-001", + "authorization_scope_ref": "scope-opaque-001", + "knowledge_cutoff": BASE_TIME + timedelta(hours=1), + } + values.update(kwargs) + return LineageAnalysisRequest(evidence=records, **values) + + +def test_request_json_keeps_email_protocol_evidence_separate_and_deterministic() -> None: + """RFC/thread fields are preserved as a separate evidence object.""" + record = evidence("mail-001") + record = LineageEvidenceRecord( + **{**record.__dict__, "email": EmailEvidence(rfc_message_id="Confirm delivery by Friday.