From f4b03acc27ec40ec4facd9d028f594195f8351fd Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Tue, 25 Aug 2026 23:49:50 +0900
Subject: [PATCH 001/393] feat(dashboard): quantify case types and preserve
project journeys
---
backend/app/operations_dashboard.py | 35 +++++++++++++++----
docs/product-technical-gap-baseline.md | 12 +++----
frontend/src/App.css | 2 ++
frontend/src/App.test.tsx | 1 +
frontend/src/App.tsx | 10 ++++++
frontend/src/api.ts | 7 ++++
.../OperationsDashboard.stories.tsx | 7 ++++
.../components/OperationsDashboard.test.tsx | 16 +++++++++
.../src/components/OperationsDashboard.tsx | 18 ++++++++--
frontend/src/components/WorkspaceNav.test.tsx | 5 +--
frontend/src/gnbChrome.ts | 1 +
frontend/src/i18n.test.ts | 2 +-
tests/test_operations_dashboard.py | 32 ++++++++++++++++-
13 files changed, 129 insertions(+), 19 deletions(-)
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index 6342d8fd4..8bbcba085 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -105,16 +105,21 @@ async def fetch_operations_dashboard(
classification.summary_text, classification.evidence_text,
classification.evidence_post_id,
coalesce(post.event_occurred_at, post.created_at) as occurred_at,
- coalesce(nullif(btrim(post.source_project_name), ''), project.project_name)
- as project_name
+ coalesce(nullif(btrim(post.source_project_name), ''), project.project_names[1])
+ as project_name,
+ coalesce(project.project_names, array[]::text[]) as project_names
from operations_case_classification classification
join source_post post on post.post_id = classification.post_id
left join lateral (
- select mention.project_name
- from post_project_mention mention
- where mention.post_id = post.post_id
- order by mention.confidence desc, mention.project_name, mention.project_key
- limit 1
+ select array_agg(names.project_name order by names.project_name) as project_names
+ from (
+ select nullif(btrim(post.source_project_name), '') as project_name
+ union
+ select nullif(btrim(mention.project_name), '')
+ from post_project_mention mention
+ where mention.post_id = post.post_id
+ ) names
+ where names.project_name is not null
) project on true
where {visible}
order by coalesce(post.event_occurred_at, post.created_at) desc,
@@ -148,6 +153,12 @@ async def fetch_operations_dashboard(
)
total = int(metrics["total_post_count"])
external = int(metrics["external_post_count"])
+ case_post_ids: dict[str, set[str]] = {}
+ case_event_counts: dict[str, int] = {}
+ for row in case_rows:
+ kind = row["case_kind_code"]
+ case_post_ids.setdefault(kind, set()).add(str(row["post_id"]))
+ case_event_counts[kind] = case_event_counts.get(kind, 0) + 1
return {
"period_label": _period_label(period_start, period_end),
"total_post_count": total,
@@ -156,12 +167,22 @@ async def fetch_operations_dashboard(
"external_percent": external * 100 / total if total else 0.0,
"pending_analysis_count": int(metrics["pending_analysis_count"]),
"failed_analysis_count": int(metrics["failed_analysis_count"]),
+ "case_metrics": [
+ {
+ "case_kind_code": kind,
+ "case_kind_label": label,
+ "event_count": case_event_counts.get(kind, 0),
+ "post_count": len(case_post_ids.get(kind, set())),
+ }
+ for kind, label in CASE_KIND_LABELS.items()
+ ],
"cases": [
{
"post_id": str(row["post_id"]),
"case_kind_code": row["case_kind_code"],
"case_kind_label": CASE_KIND_LABELS[row["case_kind_code"]],
"project_name": row["project_name"],
+ "project_names": list(row["project_names"]),
"summary_text": row["summary_text"],
"evidence_text": row["evidence_text"],
"evidence_post_id": str(row["evidence_post_id"]),
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 9a65c2eb6..ae8ea7524 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -1,17 +1,17 @@
# Product & Technical Gap Baseline
-> Dashboard delivery snapshot: 2026-08-25 21:34 KST. Protected `main` was
-> `d7d5eeb310b055b5e138060cf2dfb929b03090a6`. This local branch is not
+> Dashboard delivery snapshot: 2026-08-25 23:54 KST. Protected `main` was
+> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. This local branch is not
> protected-main release evidence.
## Operations Dashboard PRD/TRD traceability
| Requirement | Evidence contract | Delivery state |
|---|---|---|
-| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate implementation; authenticated runtime acceptance pending |
-| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate implementation; corpus backfill pending |
-| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate implementation; no separate Board by product decision |
-| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API and ordered journey UI implemented; authenticated runtime acceptance pending |
+| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate API/UI now reports separate per-kind Event and distinct-post counts beside cited qualitative facts; authenticated runtime acceptance pending |
+| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate API/UI now reports separate per-kind Event and distinct-post counts; corpus backfill pending |
+| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated runtime acceptance pending |
+| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated runtime acceptance pending |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending |
| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending |
diff --git a/frontend/src/App.css b/frontend/src/App.css
index eeaa61299..79aa1c852 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1299,6 +1299,7 @@
.dashboard-metrics > div:last-child { border-right: 0; }
.dashboard-metrics dt { color: var(--color-text); font-size: 0.875rem; }
.dashboard-metrics dd { margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700; }
+.dashboard-case-metrics .dashboard-metrics { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.dashboard-case-grid {
display: grid;
@@ -1353,6 +1354,7 @@
.operations-dashboard { padding: 1rem; }
.operations-dashboard-heading { align-items: start; flex-direction: column; }
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .dashboard-case-metrics .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-case-grid { grid-template-columns: 1fr; }
}
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 2dee4513d..fa941721d 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -4198,6 +4198,7 @@ describe("App, authenticated", () => {
expect(screen.getByRole("button", { name: "게시판" })).toHaveAttribute("aria-current", "page");
expect(within(nav).getAllByRole("button").map((button) => button.textContent)).toEqual([
"Dashboard",
+ "외부 정보",
"게시판",
"고객 마스터",
"달력",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 76ff51dec..b0dc2ff79 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5070,6 +5070,16 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
}}
/>
) : null}
+ {destination === "external" ? (
+ {
+ setPostToOpen(postId);
+ setDestination("board");
+ }}
+ />
+ ) : null}
{destination === "board" ? (
;
cases: OperationsDashboardCase[];
}
diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx
index 5187bafcc..5a545bc4b 100644
--- a/frontend/src/components/OperationsDashboard.stories.tsx
+++ b/frontend/src/components/OperationsDashboard.stories.tsx
@@ -12,6 +12,12 @@ export const EvidenceReady: Story = {
data: {
period_label: "2026-08-01–2026-08-25 · Event time", total_post_count: 40, total_event_count: 17,
external_post_count: 9, external_percent: 22.5, pending_analysis_count: 3,
+ case_metrics: [
+ { case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 규명", event_count: 7, post_count: 5 },
+ { case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", event_count: 4, post_count: 3 },
+ { case_kind_code: "external_information", case_kind_label: "발주 공고 · 시장 동향", event_count: 9, post_count: 9 },
+ { case_kind_code: "repeat_issue", case_kind_label: "반복 이슈", event_count: 2, post_count: 2 },
+ ],
failed_analysis_count: 0,
cases: [
{ post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }] },
@@ -25,6 +31,7 @@ export const EvidenceReady: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("9건 · 22.5%")).toBeInTheDocument();
+ await expect(canvas.getByText("7 Event · 5글")).toBeVisible();
await expect(canvas.getAllByRole("button", { name: "분류 근거 글 열기" })[0]).toBeVisible();
},
};
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index 02f2089e8..cf8b9bcea 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -17,6 +17,10 @@ const data = {
external_percent: 25,
pending_analysis_count: 2,
failed_analysis_count: 0,
+ case_metrics: [
+ { case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 규명", event_count: 3, post_count: 2 },
+ { case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", event_count: 2, post_count: 2 },
+ ],
cases: [{
post_id: "post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적",
project_name: "Synthetic Grid Upgrade", summary_text: "사양 변경 이후 원인 수주를 확인했습니다.", evidence_text: "Revision B changed the enclosure.", evidence_post_id: "evidence-post-1", occurred_at: "2026-08-12T00:00:00Z",
@@ -28,6 +32,7 @@ describe("OperationsDashboardView", () => {
it("distinguishes posts, events, percentages and opens evidence", async () => {
const onOpenPost = vi.fn();
render( );
+ expect(screen.getByText("3 Event · 2글")).toBeInTheDocument();
expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument();
expect(screen.getByText("원인 수주")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" }));
@@ -41,6 +46,17 @@ describe("OperationsDashboardView", () => {
expect(screen.getByRole("status")).toHaveTextContent("분석 대기 건부터 처리하세요");
});
+ it("places multi-project evidence in every explicit journey and orders events oldest first", () => {
+ const later = { ...data.cases[0], post_id: "post-later", occurred_at: "2026-08-20T00:00:00Z", project_names: ["Synthetic Grid Upgrade", "Synthetic Relay Renewal"] };
+ const earlier = { ...data.cases[0], post_id: "post-earlier", occurred_at: "2026-08-01T00:00:00Z", project_names: ["Synthetic Grid Upgrade"] };
+ render( undefined} />);
+
+ expect(screen.getByRole("heading", { name: "Synthetic Relay Renewal" })).toBeInTheDocument();
+ const primaryJourney = screen.getByRole("heading", { name: "Synthetic Grid Upgrade" }).parentElement;
+ expect(primaryJourney?.querySelectorAll("time")[0]).toHaveAttribute("datetime", earlier.occurred_at);
+ expect(primaryJourney?.querySelectorAll("time")[1]).toHaveAttribute("datetime", later.occurred_at);
+ });
+
it("separates failed analysis from pending work and gives the next action", () => {
render( undefined} />);
expect(screen.getByText("분석 실패").nextElementSibling).toHaveTextContent("2");
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index e207f474e..6b2890647 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -54,7 +54,8 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
const cases = externalOnly ? data.cases.filter((item) => item.case_kind_code === "external_information") : data.cases;
const journeys = Object.entries(
cases.reduce>((groups, item) => {
- if (item.project_name) (groups[item.project_name] ??= []).push(item);
+ const projects = item.project_names ?? (item.project_name ? [item.project_name] : []);
+ projects.forEach((project) => (groups[project] ??= []).push(item));
return groups;
}, {}),
);
@@ -71,6 +72,19 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
분석 대기 {data.pending_analysis_count}
분석 실패 {data.failed_analysis_count}
+ {!externalOnly ? (
+
+ 업무 유형별 현황
+
+ {data.case_metrics.map((metric) => (
+
+
{metric.case_kind_label}
+ {metric.event_count} Event · {metric.post_count}글
+
+ ))}
+
+
+ ) : null}
{!externalOnly && journeys.length ? (
프로젝트 여정
@@ -78,7 +92,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{project}
- {(events ?? []).map((event) => (
+ {[...(events ?? [])].sort((left, right) => left.occurred_at.localeCompare(right.occurred_at)).map((event) => (
onOpenPost(event.post_id)}>
{event.occurred_at.slice(0, 10)}
diff --git a/frontend/src/components/WorkspaceNav.test.tsx b/frontend/src/components/WorkspaceNav.test.tsx
index 8bdc3325f..31632dd7c 100644
--- a/frontend/src/components/WorkspaceNav.test.tsx
+++ b/frontend/src/components/WorkspaceNav.test.tsx
@@ -14,7 +14,7 @@ describe("WorkspaceNav", () => {
expect(initialWorkspaceDestination("", false)).toBe("dashboard");
});
- it("renders the Dashboard and four analyst destinations and marks the current page", () => {
+ it("renders the Dashboard and five analyst destinations and marks the current page", () => {
render( );
const nav = screen.getByRole("navigation");
@@ -29,13 +29,14 @@ describe("WorkspaceNav", () => {
expect(nav.textContent).not.toMatch(/Buyer|Cubee|Customer master/i);
});
- it.each(SUPPORTED_LOCALES)("keeps the four Korean GNB labels in %s", (locale) => {
+ it.each(SUPPORTED_LOCALES)("keeps the five Korean GNB labels in %s", (locale) => {
setLocale(locale);
render( );
const nav = screen.getByRole("navigation");
expect(within(nav).getAllByRole("button").map((button) => button.textContent)).toEqual([
"Dashboard",
+ "외부 정보",
"게시판",
"고객 마스터",
"달력",
diff --git a/frontend/src/gnbChrome.ts b/frontend/src/gnbChrome.ts
index 77177fe49..f5c126648 100644
--- a/frontend/src/gnbChrome.ts
+++ b/frontend/src/gnbChrome.ts
@@ -2,6 +2,7 @@
export const ANALYST_GNB_ITEMS = [
{ id: "dashboard", label: "Dashboard" },
+ { id: "external", label: "외부 정보" },
{ id: "board", label: "게시판" },
{ id: "customers", label: "고객 마스터" },
{ id: "calendar", label: "달력" },
diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts
index 18485aba9..b647a585b 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -109,7 +109,7 @@ describe("i18n", () => {
);
it("keeps analyst GNB chrome on the Dashboard and four Korean labels", () => {
- expect(ANALYST_GNB_LABELS).toEqual(["Dashboard", "게시판", "고객 마스터", "달력", "Ask Agent"]);
+ expect(ANALYST_GNB_LABELS).toEqual(["Dashboard", "외부 정보", "게시판", "고객 마스터", "달력", "Ask Agent"]);
expect(ANALYST_GNB_LABELS.join(" ")).not.toMatch(/Buyer|Cubee|Board|Customer master/);
expect(CALENDAR_CONSUME_UNAVAILABLE).toBe("이 범위의 일정을 아직 받을 수 없습니다");
});
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index 65fdbb7ce..9f67303d9 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -45,6 +45,7 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
"evidence_text": "Synthetic cited sentence",
"evidence_post_id": "00000000-0000-0000-0000-000000000002",
"project_name": "Synthetic Project",
+ "project_names": ["Synthetic Project", "Synthetic Secondary Project"],
"occurred_at": datetime(2026, 8, 12, tzinfo=timezone.utc),
}
]
@@ -66,12 +67,39 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None:
assert result["period_label"] == "2026-08-01 ~ 2026-08-31 · Event 발생일"
assert result["external_percent"] == 25.0
assert result["failed_analysis_count"] == 2
+ assert result["case_metrics"] == [
+ {
+ "case_kind_code": "claim_investigation",
+ "case_kind_label": "클레임 원인 규명",
+ "event_count": 1,
+ "post_count": 1,
+ },
+ {
+ "case_kind_code": "rebid_handover",
+ "case_kind_label": "재입찰 · 인수인계",
+ "event_count": 0,
+ "post_count": 0,
+ },
+ {
+ "case_kind_code": "external_information",
+ "case_kind_label": "발주 공고 · 시장 동향",
+ "event_count": 0,
+ "post_count": 0,
+ },
+ {
+ "case_kind_code": "repeat_issue",
+ "case_kind_label": "반복 이슈",
+ "event_count": 0,
+ "post_count": 0,
+ },
+ ]
assert result["cases"] == [
{
"post_id": "00000000-0000-0000-0000-000000000001",
"case_kind_code": "claim_investigation",
"case_kind_label": "클레임 원인 규명",
"project_name": "Synthetic Project",
+ "project_names": ["Synthetic Project", "Synthetic Secondary Project"],
"summary_text": "원인 수주가 연결됨",
"evidence_text": "Synthetic cited sentence",
"evidence_post_id": "00000000-0000-0000-0000-000000000002",
@@ -112,7 +140,9 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
self.queries.append((query, args))
return []
- assert (await fetch_operations_dashboard(EmptyConnection(), []))["external_percent"] == 0.0
+ empty = await fetch_operations_dashboard(EmptyConnection(), [])
+ assert empty["external_percent"] == 0.0
+ assert all(metric["event_count"] == metric["post_count"] == 0 for metric in empty["case_metrics"])
with pytest.raises(ValueError, match="period_start"):
await fetch_operations_dashboard(
EmptyConnection(), [], [], date(2026, 9, 1), date(2026, 8, 31)
From 724a5707a53e449cecc6dc8feb17ec25ce48bd53 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Tue, 25 Aug 2026 23:58:08 +0900
Subject: [PATCH 002/393] docs: refresh open queue exact heads
---
docs/product-technical-gap-baseline.md | 27 +++++++++++++-------------
1 file changed, 14 insertions(+), 13 deletions(-)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index ae8ea7524..7de069ed8 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -1,6 +1,6 @@
# Product & Technical Gap Baseline
-> Dashboard delivery snapshot: 2026-08-25 23:54 KST. Protected `main` was
+> Dashboard delivery snapshot: 2026-08-26 00:20 KST. Protected `main` was
> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. This local branch is not
> protected-main release evidence.
@@ -60,18 +60,15 @@ only aggregate, non-identifying evidence to this repository.
### Exact open-PR boundary
-At this snapshot there were 3 open PRs and 11 open issues. Exact observed heads
-were `#628 d07d212f` (this branch's observed parent), `#627 9e0528a6`, and
-`#579 1c209c85`. PR #579 is open; its ADR 0211 reservation is why this branch's
-filter-option decision is ADR 0212. PRs #612, #614, #615, #616, and #626
-reached protected `main`; the superseded baseline PR #613 closed without merge
-and its PRD was recreated on protected main. The open heads remain blocked on
+At this snapshot there were 7 open PRs and 10 open issues. Exact observed heads
+were `#640 f4b03acc`, `#639 aee02dca`, `#636 1230a1a7`, `#632 187a4832`,
+`#631 c0022c97`, `#629 ac38c652`, and `#579 689a21b6`. All remain blocked on
hosted gates and/or independent review. These
observations are not merge readiness. Re-fetch exact heads,
unresolved threads, checks, approvals, rulesets, and merge SHA before any
lifecycle claim.
-> Audit snapshot: 2026-08-25 21:34 KST (refreshed by the autonomous merge
+> Audit snapshot: 2026-08-26 00:20 KST (refreshed by the autonomous merge
> loop). This repository records synthetic fixtures and aggregate,
> non-identifying runtime evidence only. Open PRs and local checks are not
> protected-default-branch release evidence. Identifying post identifiers,
@@ -80,17 +77,21 @@ lifecycle claim.
## 1. Exact-head and governance evidence
-The protected default branch was `d7d5eeb310b055b5e138060cf2dfb929b03090a6`
-when this baseline was refreshed. The live queue contained 3 open PRs and 11
+The protected default branch was `04e6b610655d0db91d5f7ba9486bdda1440e0b19`
+when this baseline was refreshed. The live queue contained 7 open PRs and 10
open issues. The exact-head inventory below supersedes older per-PR snapshots
elsewhere in this document; those older rows remain useful historical delivery
context only.
| PR | Exact observed head | Merge/check state at this snapshot |
| ---: | --- | --- |
-| #628 | `d07d212f` (observed parent) | this row is updated by #628 itself, so its exact head advances after the snapshot is encoded; ADR 0212 combines complete ABAC-visible filter options into one database round trip, while hosted gates and independent review remain required |
-| #627 | `9e0528a6` | repairs k6 lifecycle evidence preservation; hosted gates remain required |
-| #579 | `1c209c85` | persists leftover interaction-map coordinates and owns ADR 0211; hosted gates and independent review remain required |
+| #640 | `f4b03acc` | quantifies dashboard case metrics and preserves project journeys; hosted gates and independent review remain required |
+| #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required |
+| #636 | `1230a1a7` | publishes the calibrated external-lineage contract; hosted gates and independent review remain required |
+| #632 | `187a4832` | preserves graph-fact source provenance and authorization; hosted gates and independent review remain required |
+| #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required |
+| #629 | `ac38c652` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required |
+| #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required |
No row above is merge evidence. Immediately before any lifecycle action,
re-fetch the head, unresolved threads, formal reviews, rulesets, and same-head
From 4677052c953babfb2f14b6c44a2ec3f2d5b64231 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Wed, 26 Aug 2026 00:00:53 +0900
Subject: [PATCH 003/393] fix: preserve dashboard evidence scope
---
backend/app/operations_dashboard.py | 13 +++++++++++--
.../src/components/OperationsDashboard.stories.tsx | 14 ++++++++++++++
.../src/components/OperationsDashboard.test.tsx | 4 +++-
frontend/src/components/OperationsDashboard.tsx | 10 +++++-----
tests/test_operations_dashboard.py | 3 +++
5 files changed, 36 insertions(+), 8 deletions(-)
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index 8bbcba085..ca1b7d5cd 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -105,13 +105,22 @@ async def fetch_operations_dashboard(
classification.summary_text, classification.evidence_text,
classification.evidence_post_id,
coalesce(post.event_occurred_at, post.created_at) as occurred_at,
- coalesce(nullif(btrim(post.source_project_name), ''), project.project_names[1])
+ coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)
as project_name,
coalesce(project.project_names, array[]::text[]) as project_names
from operations_case_classification classification
join source_post post on post.post_id = classification.post_id
left join lateral (
- select array_agg(names.project_name order by names.project_name) as project_names
+ select array_agg(names.project_name order by names.project_name) as project_names,
+ (
+ select nullif(btrim(primary_mention.project_name), '')
+ from post_project_mention primary_mention
+ where primary_mention.post_id = post.post_id
+ and nullif(btrim(primary_mention.project_name), '') is not null
+ order by primary_mention.confidence desc,
+ primary_mention.project_name
+ limit 1
+ ) as primary_project_name
from (
select nullif(btrim(post.source_project_name), '') as project_name
union
diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx
index 5a545bc4b..4c00c8038 100644
--- a/frontend/src/components/OperationsDashboard.stories.tsx
+++ b/frontend/src/components/OperationsDashboard.stories.tsx
@@ -38,6 +38,20 @@ export const EvidenceReady: Story = {
export const NarrowViewport: Story = { ...EvidenceReady, parameters: { viewport: { defaultViewport: "mobile1" } } };
+export const ExternalInformationEmpty: Story = {
+ args: {
+ data: { ...EvidenceReady.args!.data!, cases: EvidenceReady.args!.data!.cases.filter((item) => item.case_kind_code !== "external_information") },
+ externalOnly: true,
+ onOpenPost: () => undefined,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByRole("status")).toHaveTextContent("분류된 외부 정보가 없습니다");
+ await expect(canvas.queryByText("분석 대기")).not.toBeInTheDocument();
+ await expect(canvas.queryByText("분석 실패")).not.toBeInTheDocument();
+ },
+};
+
export const AnalysisPendingAndMissingEvidence: Story = {
args: {
data: { ...EvidenceReady.args!.data!, total_event_count: 0, pending_analysis_count: 3, cases: [] },
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index cf8b9bcea..59057b0a1 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -43,7 +43,9 @@ describe("OperationsDashboardView", () => {
it("shows an actionable empty external-information state", () => {
render( undefined} />);
- expect(screen.getByRole("status")).toHaveTextContent("분석 대기 건부터 처리하세요");
+ expect(screen.getByRole("status")).toHaveTextContent("기간이나 접근 범위를 확인하세요");
+ expect(screen.queryByText("분석 대기")).not.toBeInTheDocument();
+ expect(screen.queryByText("분석 실패")).not.toBeInTheDocument();
});
it("places multi-project evidence in every explicit journey and orders events oldest first", () => {
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index 6b2890647..72fa65312 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -69,8 +69,8 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
전체 글 {data.total_post_count}
분류 Event {data.total_event_count}
외부 정보 {data.external_post_count}건 · {data.external_percent.toFixed(1)}%
-
분석 대기 {data.pending_analysis_count}
-
분석 실패 {data.failed_analysis_count}
+ {!externalOnly ?
분석 대기 {data.pending_analysis_count} : null}
+ {!externalOnly ?
분석 실패 {data.failed_analysis_count} : null}
{!externalOnly ? (
@@ -116,10 +116,10 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
))}
- {cases.length === 0 && data.failed_analysis_count === 0 ? (
- {data.pending_analysis_count > 0 ? "선택 기간에 분석 완료된 근거가 없습니다. 분석 대기 건부터 처리하세요." : "선택 기간에 분석할 수 있는 근거가 없습니다. 기간이나 접근 범위를 확인하세요."}
+ {cases.length === 0 && (externalOnly || data.failed_analysis_count === 0) ? (
+ {externalOnly ? "선택 기간에 분류된 외부 정보가 없습니다. 기간이나 접근 범위를 확인하세요." : data.pending_analysis_count > 0 ? "선택 기간에 분석 완료된 근거가 없습니다. 분석 대기 건부터 처리하세요." : "선택 기간에 분석할 수 있는 근거가 없습니다. 기간이나 접근 범위를 확인하세요."}
) : null}
- {data.failed_analysis_count > 0 ? 분석 실패 {data.failed_analysis_count}건을 재처리한 뒤 근거 누락 여부를 다시 확인하세요.
: null}
+ {!externalOnly && data.failed_analysis_count > 0 ? 분석 실패 {data.failed_analysis_count}건을 재처리한 뒤 근거 누락 여부를 다시 확인하세요.
: null}
);
}
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index 9f67303d9..a9ed05915 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -122,6 +122,9 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None:
assert "process_unit_id::text = any($2::text[])" in query
assert "coalesce(post.event_occurred_at, post.created_at)" in query
assert args[1:] == (["00000000-0000-0000-0000-000000000008"], date(2026, 8, 1), date(2026, 8, 31))
+ case_query = conn.queries[1][0]
+ assert "order by primary_mention.confidence desc" in case_query
+ assert "coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)" in case_query
@pytest.mark.anyio
From 41527fa956ab7d29f3b9566d07898e0963f96e11 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Wed, 26 Aug 2026 00:05:49 +0900
Subject: [PATCH 004/393] docs: refresh exact PR and UI evidence
---
docs/product-technical-gap-baseline.md | 21 +++++++++++++--------
1 file changed, 13 insertions(+), 8 deletions(-)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 7de069ed8..255a47ce5 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -1,6 +1,6 @@
# Product & Technical Gap Baseline
-> Dashboard delivery snapshot: 2026-08-26 00:20 KST. Protected `main` was
+> Dashboard delivery snapshot: 2026-08-26 00:05 KST. Protected `main` was
> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. This local branch is not
> protected-main release evidence.
@@ -55,20 +55,25 @@ card overflow. Narrow inspection showed two-column metrics, readable cards and
scrollable. No identifying runtime record or screenshot is committed. The
`EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`,
`AnalysisFailed`, and `LoadError` scenes cover the ADR 0206 state inventory.
+The exact #640 head `4677052c` adds `ExternalInformationEmpty`; its Storybook
+build was inspected at 1440×1000 and 390×844 and exposes neither corpus-wide
+pending/failed counts nor a misleading corpus failure alert in that scoped
+destination. Screenshots remain local synthetic audit evidence and are not
+committed.
Authenticated authorized-corpus acceptance remains separate and may return
only aggregate, non-identifying evidence to this repository.
### Exact open-PR boundary
At this snapshot there were 7 open PRs and 10 open issues. Exact observed heads
-were `#640 f4b03acc`, `#639 aee02dca`, `#636 1230a1a7`, `#632 187a4832`,
-`#631 c0022c97`, `#629 ac38c652`, and `#579 689a21b6`. All remain blocked on
+were `#640 4677052c`, `#639 aee02dca`, `#636 eeeb23c6`, `#632 3851c7cf`,
+`#631 c0022c97`, `#629 0f4665b5`, and `#579 689a21b6`. All remain blocked on
hosted gates and/or independent review. These
observations are not merge readiness. Re-fetch exact heads,
unresolved threads, checks, approvals, rulesets, and merge SHA before any
lifecycle claim.
-> Audit snapshot: 2026-08-26 00:20 KST (refreshed by the autonomous merge
+> Audit snapshot: 2026-08-26 00:05 KST (refreshed by the autonomous merge
> loop). This repository records synthetic fixtures and aggregate,
> non-identifying runtime evidence only. Open PRs and local checks are not
> protected-default-branch release evidence. Identifying post identifiers,
@@ -85,12 +90,12 @@ context only.
| PR | Exact observed head | Merge/check state at this snapshot |
| ---: | --- | --- |
-| #640 | `f4b03acc` | quantifies dashboard case metrics and preserves project journeys; hosted gates and independent review remain required |
+| #640 | `4677052c` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and keeps scoped external evidence honest; hosted gates and independent review remain required |
| #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required |
-| #636 | `1230a1a7` | publishes the calibrated external-lineage contract; hosted gates and independent review remain required |
-| #632 | `187a4832` | preserves graph-fact source provenance and authorization; hosted gates and independent review remain required |
+| #636 | `eeeb23c6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required |
+| #632 | `3851c7cf` | preserves graph-fact source provenance and authorization with a static landing-query contract; hosted gates and independent review remain required |
| #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required |
-| #629 | `ac38c652` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required |
+| #629 | `0f4665b5` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required |
| #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required |
No row above is merge evidence. Immediately before any lifecycle action,
From bdef6531e199642a4836bff0c591e34a40ac26a5 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Wed, 26 Aug 2026 00:10:04 +0900
Subject: [PATCH 005/393] docs: refresh exact-head queue snapshot
---
docs/product-technical-gap-baseline.md | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 255a47ce5..24bb941e9 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -1,6 +1,6 @@
# Product & Technical Gap Baseline
-> Dashboard delivery snapshot: 2026-08-26 00:05 KST. Protected `main` was
+> Dashboard delivery snapshot: 2026-08-26 01:10 KST. Protected `main` was
> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. This local branch is not
> protected-main release evidence.
@@ -66,14 +66,14 @@ only aggregate, non-identifying evidence to this repository.
### Exact open-PR boundary
At this snapshot there were 7 open PRs and 10 open issues. Exact observed heads
-were `#640 4677052c`, `#639 aee02dca`, `#636 eeeb23c6`, `#632 3851c7cf`,
+were `#640 41527fa9`, `#639 aee02dca`, `#636 eeeb23c6`, `#632 bfeaecd9`,
`#631 c0022c97`, `#629 0f4665b5`, and `#579 689a21b6`. All remain blocked on
hosted gates and/or independent review. These
observations are not merge readiness. Re-fetch exact heads,
unresolved threads, checks, approvals, rulesets, and merge SHA before any
lifecycle claim.
-> Audit snapshot: 2026-08-26 00:05 KST (refreshed by the autonomous merge
+> Audit snapshot: 2026-08-26 01:10 KST (refreshed by the autonomous merge
> loop). This repository records synthetic fixtures and aggregate,
> non-identifying runtime evidence only. Open PRs and local checks are not
> protected-default-branch release evidence. Identifying post identifiers,
@@ -90,10 +90,10 @@ context only.
| PR | Exact observed head | Merge/check state at this snapshot |
| ---: | --- | --- |
-| #640 | `4677052c` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and keeps scoped external evidence honest; hosted gates and independent review remain required |
+| #640 | `41527fa9` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and keeps scoped external evidence honest; hosted gates and independent review remain required |
| #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required |
| #636 | `eeeb23c6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required |
-| #632 | `3851c7cf` | preserves graph-fact source provenance and authorization with a static landing-query contract; hosted gates and independent review remain required |
+| #632 | `bfeaecd9` | preserves graph-fact source provenance and authorization with a static landing-query contract; hosted gates and independent review remain required |
| #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required |
| #629 | `0f4665b5` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required |
| #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required |
From 686cc5d26b38a01301830b9bdc0a28e877d9d47e Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Wed, 26 Aug 2026 00:10:03 +0900
Subject: [PATCH 006/393] fix: scope external dashboard metrics
---
docs/product-technical-gap-baseline.md | 10 +++++-----
.../src/components/OperationsDashboard.stories.tsx | 2 ++
frontend/src/components/OperationsDashboard.test.tsx | 2 ++
frontend/src/components/OperationsDashboard.tsx | 4 ++--
4 files changed, 11 insertions(+), 7 deletions(-)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 24bb941e9..270231981 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -55,9 +55,9 @@ card overflow. Narrow inspection showed two-column metrics, readable cards and
scrollable. No identifying runtime record or screenshot is committed. The
`EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`,
`AnalysisFailed`, and `LoadError` scenes cover the ADR 0206 state inventory.
-The exact #640 head `4677052c` adds `ExternalInformationEmpty`; its Storybook
+The current #640 candidate adds `ExternalInformationEmpty`; its Storybook
build was inspected at 1440×1000 and 390×844 and exposes neither corpus-wide
-pending/failed counts nor a misleading corpus failure alert in that scoped
+total/pending/failed counts nor a misleading corpus failure alert in that scoped
destination. Screenshots remain local synthetic audit evidence and are not
committed.
Authenticated authorized-corpus acceptance remains separate and may return
@@ -66,7 +66,7 @@ only aggregate, non-identifying evidence to this repository.
### Exact open-PR boundary
At this snapshot there were 7 open PRs and 10 open issues. Exact observed heads
-were `#640 41527fa9`, `#639 aee02dca`, `#636 eeeb23c6`, `#632 bfeaecd9`,
+were `#640 41527fa9` (this branch's observed parent), `#639 aee02dca`, `#636 eeeb23c6`, `#632 bfeaecd9`,
`#631 c0022c97`, `#629 0f4665b5`, and `#579 689a21b6`. All remain blocked on
hosted gates and/or independent review. These
observations are not merge readiness. Re-fetch exact heads,
@@ -90,10 +90,10 @@ context only.
| PR | Exact observed head | Merge/check state at this snapshot |
| ---: | --- | --- |
-| #640 | `41527fa9` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and keeps scoped external evidence honest; hosted gates and independent review remain required |
+| #640 | `41527fa9` (observed parent) | this row is updated by #640 itself, so its exact head advances after the snapshot is encoded; it quantifies dashboard case metrics, preserves confidence-ranked project labels, and keeps scoped external evidence honest; hosted gates and independent review remain required |
| #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required |
| #636 | `eeeb23c6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required |
-| #632 | `bfeaecd9` | preserves graph-fact source provenance and authorization with a static landing-query contract; hosted gates and independent review remain required |
+| #632 | `bfeaecd9` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
| #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required |
| #629 | `0f4665b5` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required |
| #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required |
diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx
index 4c00c8038..c9093993a 100644
--- a/frontend/src/components/OperationsDashboard.stories.tsx
+++ b/frontend/src/components/OperationsDashboard.stories.tsx
@@ -47,6 +47,8 @@ export const ExternalInformationEmpty: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("status")).toHaveTextContent("분류된 외부 정보가 없습니다");
+ await expect(canvas.queryByText("전체 글")).not.toBeInTheDocument();
+ await expect(canvas.queryByText("분류 Event")).not.toBeInTheDocument();
await expect(canvas.queryByText("분석 대기")).not.toBeInTheDocument();
await expect(canvas.queryByText("분석 실패")).not.toBeInTheDocument();
},
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index 59057b0a1..7124d7ca7 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -46,6 +46,8 @@ describe("OperationsDashboardView", () => {
expect(screen.getByRole("status")).toHaveTextContent("기간이나 접근 범위를 확인하세요");
expect(screen.queryByText("분석 대기")).not.toBeInTheDocument();
expect(screen.queryByText("분석 실패")).not.toBeInTheDocument();
+ expect(screen.queryByText("전체 글")).not.toBeInTheDocument();
+ expect(screen.queryByText("분류 Event")).not.toBeInTheDocument();
});
it("places multi-project evidence in every explicit journey and orders events oldest first", () => {
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index 72fa65312..810795a00 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -66,8 +66,8 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
수치를 선택하면 근거 글에서 다음 조치를 확인할 수 있습니다.
-
전체 글 {data.total_post_count}
-
분류 Event {data.total_event_count}
+ {!externalOnly ?
전체 글 {data.total_post_count} : null}
+ {!externalOnly ?
분류 Event {data.total_event_count} : null}
외부 정보 {data.external_post_count}건 · {data.external_percent.toFixed(1)}%
{!externalOnly ?
분석 대기 {data.pending_analysis_count} : null}
{!externalOnly ?
분석 실패 {data.failed_analysis_count} : null}
From 68e52a8b94c9b9be805db1cfb4979b63cab3b848 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Wed, 26 Aug 2026 00:11:58 +0900
Subject: [PATCH 007/393] docs(ui): align analyst destination count
---
frontend/src/gnbChrome.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/gnbChrome.ts b/frontend/src/gnbChrome.ts
index f5c126648..2067ecadb 100644
--- a/frontend/src/gnbChrome.ts
+++ b/frontend/src/gnbChrome.ts
@@ -1,4 +1,4 @@
-/** Analyst GNB chrome: four Korean destinations, no Buyer/Cubee labels. */
+/** Analyst GNB chrome: six Korean destinations, no Buyer/Cubee labels. */
export const ANALYST_GNB_ITEMS = [
{ id: "dashboard", label: "Dashboard" },
From f9a7a78394a3ef566ea5fd11b0018fcef3c17b68 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 08:15:27 -0700
Subject: [PATCH 008/393] feat(dashboard): persist unsupported required answers
(#642)
Co-authored-by: seonghobae
---
backend/app/operations_case_ingestion.py | 5 +++
backend/app/operations_dashboard.py | 20 +++++++++++
.../adr/0206-evidence-operations-dashboard.md | 8 ++++-
docs/product-technical-gap-baseline.md | 4 +--
docs/storybook-inventory.md | 2 +-
frontend/src/api.ts | 1 +
.../OperationsDashboard.stories.tsx | 18 +++++++---
.../components/OperationsDashboard.test.tsx | 2 ++
.../src/components/OperationsDashboard.tsx | 6 ++++
lineageweave/operations_case_analysis.py | 30 +++++++++++++---
.../0211_operations_case_missing_fact.sql | 13 +++++++
tests/test_operations_case_analysis.py | 34 +++++++++++++++----
tests/test_operations_case_ingestion.py | 23 +++++++++++++
tests/test_operations_dashboard.py | 11 +++++-
tests/test_schema.py | 16 +++++++++
15 files changed, 174 insertions(+), 19 deletions(-)
create mode 100644 migrations/0211_operations_case_missing_fact.sql
diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py
index a2a1fc84f..92cfc1fc8 100644
--- a/backend/app/operations_case_ingestion.py
+++ b/backend/app/operations_case_ingestion.py
@@ -61,3 +61,8 @@ async def persist_operations_cases(
for ordinal, fact in enumerate(case.facts)
],
)
+ if case.missing_fact_type_codes:
+ await conn.executemany(
+ "insert into operations_case_missing_fact (post_id, case_kind_code, fact_type_code) values ($1, $2, $3)",
+ [(post_id, case.case_kind_code, code) for code in case.missing_fact_type_codes],
+ )
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index ca1b7d5cd..af610cf43 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -148,6 +148,16 @@ async def fetch_operations_dashboard(
""",
*args,
)
+ missing_rows = await conn.fetch(
+ f"""
+ select missing.post_id, missing.case_kind_code, missing.fact_type_code
+ from operations_case_missing_fact missing
+ join source_post post on post.post_id = missing.post_id
+ where {visible}
+ order by missing.post_id, missing.case_kind_code, missing.fact_type_code
+ """,
+ *args,
+ )
facts: dict[tuple[str, str], list[dict[str, str]]] = {}
for row in fact_rows:
key = (str(row["post_id"]), row["case_kind_code"])
@@ -160,6 +170,15 @@ async def fetch_operations_dashboard(
"evidence_post_id": str(row["evidence_post_id"]),
}
)
+ missing_facts: dict[tuple[str, str], list[dict[str, str]]] = {}
+ for row in missing_rows:
+ key = (str(row["post_id"]), row["case_kind_code"])
+ missing_facts.setdefault(key, []).append(
+ {
+ "fact_type_code": row["fact_type_code"],
+ "fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]],
+ }
+ )
total = int(metrics["total_post_count"])
external = int(metrics["external_post_count"])
case_post_ids: dict[str, set[str]] = {}
@@ -197,6 +216,7 @@ async def fetch_operations_dashboard(
"evidence_post_id": str(row["evidence_post_id"]),
"occurred_at": row["occurred_at"].isoformat(),
"facts": facts.get((str(row["post_id"]), row["case_kind_code"]), []),
+ "missing_facts": missing_facts.get((str(row["post_id"]), row["case_kind_code"]), []),
}
for row in case_rows
],
diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
index 3f9f5505d..ce6a8c2ee 100644
--- a/docs/adr/0206-evidence-operations-dashboard.md
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -65,8 +65,14 @@ provenance.
post evidence.
8. Claim-investigation and rebid/handover panels include positively classified
cases and show extracted answers plus cited spans. A required answer that
- the source does not support is stored as an explicit missing fact, so the
+ the source does not support is stored in the normalized
+ `operations_case_missing_fact` relation as an explicit missing fact, so the
next action is collection or human correction rather than keyword guessing.
+ A provider result is invalid unless every required question is represented
+ exactly once as either a cited supported fact or an explicit missing fact;
+ a fact cannot be both. Missing facts carry no invented value or evidence
+ span and inherit the analysis run and authorized-source boundary through
+ their classification parent.
9. Project journeys group events only by an explicit source project or stored
semantic project mention. A multi-project post may appear in multiple
journeys. Unbound events remain visible as unassigned evidence and are not
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 270231981..f73925b70 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -8,8 +8,8 @@
| Requirement | Evidence contract | Delivery state |
|---|---|---|
-| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate API/UI now reports separate per-kind Event and distinct-post counts beside cited qualitative facts; authenticated runtime acceptance pending |
-| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate API/UI now reports separate per-kind Event and distinct-post counts; corpus backfill pending |
+| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate API/UI reports separate per-kind Event and distinct-post counts; every required answer is cited or stored as an explicit missing fact with a collection action; authenticated runtime acceptance pending |
+| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate API/UI reports separate per-kind Event and distinct-post counts; every required answer is cited or stored as an explicit missing fact; corpus backfill pending |
| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated runtime acceptance pending |
| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated runtime acceptance pending |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index 3716656cd..3559cf139 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -5,7 +5,7 @@ operator-facing control you can click before changing product CSS.
| Story | Operator next action | Token / module |
|---|---|---|
-| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` |
+| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, explicit evidence-absence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` |
| `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` |
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
| `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` |
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 60c8b0c6f..c63436b9f 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -60,6 +60,7 @@ export interface OperationsDashboardCase {
evidence_post_id: string;
occurred_at: string;
facts: OperationsDashboardFact[];
+ missing_facts: Array<{ fact_type_code: string; fact_type_label: string }>;
}
export interface OperationsDashboardResponse {
diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx
index c9093993a..560bbbe33 100644
--- a/frontend/src/components/OperationsDashboard.stories.tsx
+++ b/frontend/src/components/OperationsDashboard.stories.tsx
@@ -20,10 +20,10 @@ export const EvidenceReady: Story = {
],
failed_analysis_count: 0,
cases: [
- { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }] },
- { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }] },
- { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "business_relation", fact_type_label: "사업 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3" }] },
- { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }] },
+ { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }], missing_facts: [{ fact_type_code: "order", fact_type_label: "발생 수주" }, { fact_type_code: "specification_change", fact_type_label: "사양 변경" }, { fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }] },
+ { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }], missing_facts: [{ fact_type_code: "discussion", fact_type_label: "협의 내용" }, { fact_type_code: "counterparty", fact_type_label: "협의 상대" }, { fact_type_code: "our_owner", fact_type_label: "우리측 담당자" }] },
+ { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "external_relation", fact_type_label: "업무 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3" }], missing_facts: [] },
+ { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }], missing_facts: [{ fact_type_code: "issue_pattern", fact_type_label: "반복 유형" }] },
],
},
onOpenPost: () => undefined,
@@ -54,6 +54,16 @@ export const ExternalInformationEmpty: Story = {
},
};
+export const RequiredFactMissing: Story = {
+ args: {
+ data: { ...EvidenceReady.args!.data!, cases: [EvidenceReady.args!.data!.cases[0]] },
+ onOpenPost: () => undefined,
+ },
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).getByText(/수주 Pool: 권한 범위 내 근거가 없습니다/)).toBeVisible();
+ },
+};
+
export const AnalysisPendingAndMissingEvidence: Story = {
args: {
data: { ...EvidenceReady.args!.data!, total_event_count: 0, pending_analysis_count: 3, cases: [] },
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index 7124d7ca7..a3947e2a2 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -25,6 +25,7 @@ const data = {
post_id: "post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적",
project_name: "Synthetic Grid Upgrade", summary_text: "사양 변경 이후 원인 수주를 확인했습니다.", evidence_text: "Revision B changed the enclosure.", evidence_post_id: "evidence-post-1", occurred_at: "2026-08-12T00:00:00Z",
facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "ORDER-100", evidence_text: "Original order ORDER-100", evidence_post_id: "evidence-post-2" }],
+ missing_facts: [{ fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }],
}],
};
@@ -35,6 +36,7 @@ describe("OperationsDashboardView", () => {
expect(screen.getByText("3 Event · 2글")).toBeInTheDocument();
expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument();
expect(screen.getByText("원인 수주")).toBeInTheDocument();
+ expect(screen.getByText(/수주 Pool: 권한 범위 내 근거가 없습니다/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" }));
expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1");
await userEvent.click(screen.getByRole("button", { name: "원인 수주 근거 열기" }));
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index 810795a00..cd739c0d6 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -112,6 +112,12 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.summary_text}
{item.evidence_text}
{item.facts.map((fact) =>
{fact.fact_type_label} {fact.value_text} onOpenPost(fact.evidence_post_id)}>{fact.fact_type_label} 근거 열기 )}
+ {item.missing_facts.length ? (
+
+ 추가 확인 필요
+ {item.missing_facts.map((fact) => {fact.fact_type_label}: 권한 범위 내 근거가 없습니다. 관련 원문을 연결하세요. )}
+
+ ) : null}
onOpenPost(item.evidence_post_id)}>분류 근거 글 열기
))}
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py
index 43decd006..5e4c46fde 100644
--- a/lineageweave/operations_case_analysis.py
+++ b/lineageweave/operations_case_analysis.py
@@ -19,6 +19,12 @@
"issue_pattern", "improvement_action",
}
)
+REQUIRED_FACT_TYPES = {
+ "claim_investigation": frozenset({"order", "specification_change", "originating_order", "sales_pool"}),
+ "rebid_handover": frozenset({"discussion", "counterparty", "our_owner", "decision"}),
+ "external_information": frozenset({"external_relation"}),
+ "repeat_issue": frozenset({"issue_pattern", "improvement_action"}),
+}
@dataclass(frozen=True)
@@ -42,6 +48,7 @@ class OperationsCase:
facts: tuple[OperationsCaseFact, ...]
evidence_post_id: str = ""
evidence_input_sha256: str = ""
+ missing_fact_type_codes: tuple[str, ...] = ()
@dataclass(frozen=True)
@@ -88,8 +95,14 @@ def analyze(
evidence_post_id, evidence_text (a verbatim span from that numbered source), and facts. Each fact has
fact_type_code (one of order, specification_change, originating_order,
sales_pool, discussion, counterparty, our_owner, decision, external_relation,
-issue_pattern, improvement_action), value_text, evidence_post_id, and evidence_text (a verbatim span from that source). Return [] only when the
-record supports none of the case kinds. Never fill an unsupported fact.
+issue_pattern, improvement_action), value_text, evidence_post_id, and evidence_text (a verbatim span from that source).
+Each item must also have missing_fact_type_codes. Put every required fact type for that case
+that is not supported anywhere in the authorized sources in this array; never invent a value or
+evidence span for it. Required types are: claim_investigation = order,
+specification_change, originating_order, sales_pool; rebid_handover = discussion,
+counterparty, our_owner, decision; external_information = external_relation;
+repeat_issue = issue_pattern, improvement_action. Return [] only when the record supports none
+of the case kinds.
Stored context (hints, not proof): {context}
Authorized numbered sources:
@@ -123,8 +136,9 @@ def parse_operations_case_response(
evidence = item.get("evidence_text")
evidence_post_id = item.get("evidence_post_id") or ("focal" if legacy_focal else None)
facts = item.get("facts")
+ missing_fact_types = item.get("missing_fact_type_codes")
evidence_source = sources_by_id.get(evidence_post_id)
- if not isinstance(summary, str) or not summary.strip() or not isinstance(evidence, str) or not evidence.strip() or evidence_source is None or evidence not in evidence_source.text or not isinstance(facts, list):
+ if not isinstance(summary, str) or not summary.strip() or not isinstance(evidence, str) or not evidence.strip() or evidence_source is None or evidence not in evidence_source.text or not isinstance(facts, list) or not isinstance(missing_fact_types, list):
return None
parsed_facts: list[OperationsCaseFact] = []
for fact in facts:
@@ -137,7 +151,15 @@ def parse_operations_case_response(
if not isinstance(value, str) or not value.strip() or not isinstance(fact_evidence, str) or not fact_evidence.strip() or fact_source is None or fact_evidence not in fact_source.text:
return None
parsed_facts.append(OperationsCaseFact(fact["fact_type_code"], value.strip(), fact_evidence, fact_source.post_id, fact_source.input_sha256))
- cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts), evidence_source.post_id, evidence_source.input_sha256))
+ supported_types = {fact.fact_type_code for fact in parsed_facts}
+ if (
+ any(not isinstance(code, str) or code not in FACT_TYPES for code in missing_fact_types)
+ or len(set(missing_fact_types)) != len(missing_fact_types)
+ or supported_types.intersection(missing_fact_types)
+ or supported_types.union(missing_fact_types) != REQUIRED_FACT_TYPES[item["case_kind_code"]]
+ ):
+ return None
+ cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts), evidence_source.post_id, evidence_source.input_sha256, tuple(missing_fact_types)))
return tuple(cases)
diff --git a/migrations/0211_operations_case_missing_fact.sql b/migrations/0211_operations_case_missing_fact.sql
new file mode 100644
index 000000000..a72edcb55
--- /dev/null
+++ b/migrations/0211_operations_case_missing_fact.sql
@@ -0,0 +1,13 @@
+-- ADR 0206: unsupported required answers remain explicit without fabricated evidence.
+create table if not exists operations_case_missing_fact (
+ post_id uuid not null,
+ case_kind_code text not null,
+ fact_type_code text not null check (fact_type_code in ('order', 'specification_change', 'originating_order', 'sales_pool', 'discussion', 'counterparty', 'our_owner', 'decision', 'external_relation', 'issue_pattern', 'improvement_action')),
+ primary key (post_id, case_kind_code, fact_type_code),
+ foreign key (post_id, case_kind_code)
+ references operations_case_classification(post_id, case_kind_code)
+ on delete cascade
+);
+
+create index if not exists operations_case_missing_fact_kind_idx
+ on operations_case_missing_fact (case_kind_code, fact_type_code, post_id);
diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py
index 312189879..ebfdb20fa 100644
--- a/tests/test_operations_case_analysis.py
+++ b/tests/test_operations_case_analysis.py
@@ -9,8 +9,8 @@ def test_parses_multiple_cases_and_grounded_facts() -> None:
"""One record may support multiple case kinds without losing evidence."""
body = "The revised specification caused the claim. Mina agreed with Alex to rebid."
payload = [
- {"case_kind_code": "claim_investigation", "summary_text": "Specification-linked claim", "evidence_text": "The revised specification caused the claim.", "facts": [{"fact_type_code": "specification_change", "value_text": "revised specification", "evidence_text": "The revised specification caused the claim."}]},
- {"case_kind_code": "rebid_handover", "summary_text": "Rebid agreement", "evidence_text": "Mina agreed with Alex to rebid.", "facts": [{"fact_type_code": "counterparty", "value_text": "Mina and Alex", "evidence_text": "Mina agreed with Alex to rebid."}]},
+ {"case_kind_code": "claim_investigation", "summary_text": "Specification-linked claim", "evidence_text": "The revised specification caused the claim.", "facts": [{"fact_type_code": "specification_change", "value_text": "revised specification", "evidence_text": "The revised specification caused the claim."}], "missing_fact_type_codes": ["order", "originating_order", "sales_pool"]},
+ {"case_kind_code": "rebid_handover", "summary_text": "Rebid agreement", "evidence_text": "Mina agreed with Alex to rebid.", "facts": [{"fact_type_code": "counterparty", "value_text": "Mina and Alex", "evidence_text": "Mina agreed with Alex to rebid."}], "missing_fact_type_codes": ["discussion", "our_owner", "decision"]},
]
result = parse_operations_case_response(json.dumps(payload), body)
assert result is not None
@@ -19,7 +19,7 @@ def test_parses_multiple_cases_and_grounded_facts() -> None:
def test_rejects_uncited_model_claim() -> None:
"""A plausible answer absent from the source is not persisted."""
- payload = [{"case_kind_code": "external_information", "summary_text": "Market note", "evidence_text": "invented", "facts": []}]
+ payload = [{"case_kind_code": "external_information", "summary_text": "Market note", "evidence_text": "invented", "facts": [], "missing_fact_type_codes": ["external_relation"]}]
assert parse_operations_case_response(json.dumps(payload), "source body") is None
@@ -37,11 +37,11 @@ def test_rejects_unknown_codes_and_malformed_json() -> None:
def test_rejects_duplicate_case_kinds_and_blank_evidence() -> None:
"""One normalized key has one grounded classification, never an empty span."""
duplicate = [
- {"case_kind_code": "repeat_issue", "summary_text": "First", "evidence_text": "body", "facts": []},
- {"case_kind_code": "repeat_issue", "summary_text": "Second", "evidence_text": "body", "facts": []},
+ {"case_kind_code": "repeat_issue", "summary_text": "First", "evidence_text": "body", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]},
+ {"case_kind_code": "repeat_issue", "summary_text": "Second", "evidence_text": "body", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]},
]
blank = [
- {"case_kind_code": "repeat_issue", "summary_text": "Blank", "evidence_text": "", "facts": []}
+ {"case_kind_code": "repeat_issue", "summary_text": "Blank", "evidence_text": "", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]}
]
assert parse_operations_case_response(json.dumps(duplicate), "body") is None
assert parse_operations_case_response(json.dumps(blank), "body") is None
@@ -64,6 +64,7 @@ def test_linked_fact_retains_its_authorized_source_post_and_input_digest() -> No
"evidence_post_id": "linked",
"evidence_text": "Specification S2 replaced S1.",
}],
+ "missing_fact_type_codes": ["order", "originating_order", "sales_pool"],
}]
result = parse_operations_case_response(json.dumps(payload), sources)
@@ -73,3 +74,24 @@ def test_linked_fact_retains_its_authorized_source_post_and_input_digest() -> No
assert result[0].facts[0].evidence_input_sha256 == sources[1].input_sha256
payload[0]["facts"][0]["evidence_post_id"] = "unauthorized"
assert parse_operations_case_response(json.dumps(payload), sources) is None
+
+
+def test_requires_each_case_question_to_be_supported_or_explicitly_missing() -> None:
+ """The provider cannot silently omit or both support and miss a required answer."""
+ payload = [{
+ "case_kind_code": "external_information",
+ "summary_text": "External notice",
+ "evidence_text": "A public notice was published.",
+ "facts": [],
+ "missing_fact_type_codes": [],
+ }]
+ body = "A public notice was published."
+ assert parse_operations_case_response(json.dumps(payload), body) is None
+
+ payload[0]["facts"] = [{
+ "fact_type_code": "external_relation",
+ "value_text": "Sales opportunity",
+ "evidence_text": body,
+ }]
+ payload[0]["missing_fact_type_codes"] = ["external_relation"]
+ assert parse_operations_case_response(json.dumps(payload), body) is None
diff --git a/tests/test_operations_case_ingestion.py b/tests/test_operations_case_ingestion.py
index d1270d5ca..a1d6f63f5 100644
--- a/tests/test_operations_case_ingestion.py
+++ b/tests/test_operations_case_ingestion.py
@@ -57,3 +57,26 @@ def test_persists_supported_empty_analysis() -> None:
asyncio.run(persist_operations_cases(conn, "post-1", "ordinary", "session-1", ()))
assert len(conn.calls) == 2
assert conn.batches == []
+
+
+def test_persists_missing_required_facts_without_invented_evidence() -> None:
+ """Unsupported answers use the normalized missing-fact relation only."""
+ conn = _Connection()
+ case = OperationsCase(
+ "claim_investigation",
+ "Claim",
+ "source",
+ (),
+ "post-1",
+ "a" * 64,
+ ("order", "specification_change", "originating_order", "sales_pool"),
+ )
+
+ asyncio.run(persist_operations_cases(conn, "post-1", "source", "session-1", (case,)))
+
+ assert conn.batches == [[
+ ("post-1", "claim_investigation", "order"),
+ ("post-1", "claim_investigation", "specification_change"),
+ ("post-1", "claim_investigation", "originating_order"),
+ ("post-1", "claim_investigation", "sales_pool"),
+ ]]
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index a9ed05915..d3a6ae43e 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -37,6 +37,12 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
"fact_ordinal": 0,
}
]
+ if "operations_case_missing_fact missing" in query:
+ return [{
+ "post_id": "00000000-0000-0000-0000-000000000001",
+ "case_kind_code": "claim_investigation",
+ "fact_type_code": "sales_pool",
+ }]
return [
{
"post_id": "00000000-0000-0000-0000-000000000001",
@@ -113,9 +119,12 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None:
"evidence_post_id": "00000000-0000-0000-0000-000000000002",
}
],
+ "missing_facts": [
+ {"fact_type_code": "sales_pool", "fact_type_label": "수주 Pool"}
+ ],
}
]
- assert len(conn.queries) == 3
+ assert len(conn.queries) == 4
for query, args in conn.queries:
assert "visibility_code = 'public'" in query
assert "corporate_entity_id::text = any($1::text[])" in query
diff --git a/tests/test_schema.py b/tests/test_schema.py
index d88d07bd2..ab0a2818c 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -86,6 +86,15 @@
/ "migrations"
/ "0182_report_leftover_map_unexplained.sql"
)
+_OPERATIONS_CASE_MIGRATION = (
+ Path(__file__).resolve().parents[1] / "migrations" / "0208_operations_case_analysis.sql"
+)
+_OPERATIONS_CASE_EVIDENCE_MIGRATION = (
+ Path(__file__).resolve().parents[1] / "migrations" / "0209_operations_case_evidence_source.sql"
+)
+_OPERATIONS_CASE_MISSING_MIGRATION = (
+ Path(__file__).resolve().parents[1] / "migrations" / "0211_operations_case_missing_fact.sql"
+)
def _postgres_available() -> bool:
@@ -131,6 +140,9 @@ def schema_db():
cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text())
+ cur.execute(_OPERATIONS_CASE_MIGRATION.read_text())
+ cur.execute(_OPERATIONS_CASE_EVIDENCE_MIGRATION.read_text())
+ cur.execute(_OPERATIONS_CASE_MISSING_MIGRATION.read_text())
conn.commit()
yield conn
finally:
@@ -185,6 +197,10 @@ def test_migration_applies_cleanly(schema_db) -> None:
"post_summary_action",
"post_chat_result",
"post_chat_citation",
+ "operations_case_analysis",
+ "operations_case_classification",
+ "operations_case_fact",
+ "operations_case_missing_fact",
}
assert expected <= tables
From a42cd443861195aa4e05b9caee8000b37ae910ed Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Wed, 26 Aug 2026 00:20:51 +0900
Subject: [PATCH 009/393] docs: refresh exact open PR baseline
---
docs/product-technical-gap-baseline.md | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index f73925b70..f6cd35a4e 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -1,6 +1,6 @@
# Product & Technical Gap Baseline
-> Dashboard delivery snapshot: 2026-08-26 01:10 KST. Protected `main` was
+> Dashboard delivery snapshot: 2026-08-26 (latest exact-head fetch). Protected `main` was
> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. This local branch is not
> protected-main release evidence.
@@ -65,15 +65,15 @@ only aggregate, non-identifying evidence to this repository.
### Exact open-PR boundary
-At this snapshot there were 7 open PRs and 10 open issues. Exact observed heads
-were `#640 41527fa9` (this branch's observed parent), `#639 aee02dca`, `#636 eeeb23c6`, `#632 bfeaecd9`,
+At this snapshot there were 8 open PRs and 10 open issues. Exact observed heads
+were `#643 041ec13b`, `#640 f9a7a783`, `#639 aee02dca`, `#636 20d25fe6`, `#632 f3b5acfe`,
`#631 c0022c97`, `#629 0f4665b5`, and `#579 689a21b6`. All remain blocked on
hosted gates and/or independent review. These
observations are not merge readiness. Re-fetch exact heads,
unresolved threads, checks, approvals, rulesets, and merge SHA before any
lifecycle claim.
-> Audit snapshot: 2026-08-26 01:10 KST (refreshed by the autonomous merge
+> Audit snapshot: 2026-08-26 (refreshed by the autonomous merge
> loop). This repository records synthetic fixtures and aggregate,
> non-identifying runtime evidence only. Open PRs and local checks are not
> protected-default-branch release evidence. Identifying post identifiers,
@@ -83,17 +83,18 @@ lifecycle claim.
## 1. Exact-head and governance evidence
The protected default branch was `04e6b610655d0db91d5f7ba9486bdda1440e0b19`
-when this baseline was refreshed. The live queue contained 7 open PRs and 10
+when this baseline was refreshed. The live queue contained 8 open PRs and 10
open issues. The exact-head inventory below supersedes older per-PR snapshots
elsewhere in this document; those older rows remain useful historical delivery
context only.
| PR | Exact observed head | Merge/check state at this snapshot |
| ---: | --- | --- |
-| #640 | `41527fa9` (observed parent) | this row is updated by #640 itself, so its exact head advances after the snapshot is encoded; it quantifies dashboard case metrics, preserves confidence-ranked project labels, and keeps scoped external evidence honest; hosted gates and independent review remain required |
+| #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required |
+| #640 | `f9a7a783` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and persists explicit missing required facts; hosted gates and independent review remain required |
| #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required |
-| #636 | `eeeb23c6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required |
-| #632 | `bfeaecd9` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
+| #636 | `20d25fe6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required |
+| #632 | `f3b5acfe` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
| #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required |
| #629 | `0f4665b5` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required |
| #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required |
From 7ec16daa2506927b3ad74540c6fba519c9145703 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Wed, 26 Aug 2026 00:26:04 +0900
Subject: [PATCH 010/393] docs: align baseline with current PR heads
---
docs/product-technical-gap-baseline.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index f6cd35a4e..a9e404980 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -66,7 +66,7 @@ only aggregate, non-identifying evidence to this repository.
### Exact open-PR boundary
At this snapshot there were 8 open PRs and 10 open issues. Exact observed heads
-were `#643 041ec13b`, `#640 f9a7a783`, `#639 aee02dca`, `#636 20d25fe6`, `#632 f3b5acfe`,
+were `#643 041ec13b`, `#640 a42cd443`, `#639 aee02dca`, `#636 20d25fe6`, `#632 e1ebe50a`,
`#631 c0022c97`, `#629 0f4665b5`, and `#579 689a21b6`. All remain blocked on
hosted gates and/or independent review. These
observations are not merge readiness. Re-fetch exact heads,
@@ -91,10 +91,10 @@ context only.
| PR | Exact observed head | Merge/check state at this snapshot |
| ---: | --- | --- |
| #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required |
-| #640 | `f9a7a783` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and persists explicit missing required facts; hosted gates and independent review remain required |
+| #640 | `a42cd443` | quantifies dashboard case metrics, preserves confidence-ranked project labels, and persists explicit missing required facts; hosted gates and independent review remain required |
| #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required |
| #636 | `20d25fe6` | publishes the calibrated external-lineage contract without a redundant explicit-child filter; hosted gates and independent review remain required |
-| #632 | `f3b5acfe` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
+| #632 | `e1ebe50a` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
| #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required |
| #629 | `0f4665b5` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required |
| #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required |
From e6c0f2d3bdd0e28a5170a092bc2db49bffb60694 Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Wed, 26 Aug 2026 00:28:36 +0900
Subject: [PATCH 011/393] docs: record exact Strix gate failure
---
docs/product-technical-gap-baseline.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index a9e404980..01744a9d0 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -104,6 +104,14 @@ re-fetch the head, unresolved threads, formal reviews, rulesets, and same-head
check conclusions. In particular, queued checks are infrastructure state and
do not transfer evidence from an earlier SHA.
+The exact-head Strix check for PR #631 (`c0022c97`) failed at run
+`32855289561` with `STRIX_PROVIDER_UNAVAILABLE`; the check annotation reports
+provider/backend unavailability rather than a repository vulnerability. The
+historical workflow run is no longer retrievable through the Actions API, so it
+cannot be rerun from that run id. This remains an unresolved hosted-gate
+condition, not evidence that the PR is merge-ready; re-fetch a new exact-head
+run before any merge claim.
+
PR #607 first merged as `61fd631c7bb3c57113fd19763c2c43161eeb2824`
into #606's non-default branch. PR #606 subsequently passed the protected gate,
so the combined TEPP-consumer and operations-dashboard implementation is now
From 95491defb64effeb84fab1ad68e812005964f54e Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Wed, 26 Aug 2026 00:33:26 +0900
Subject: [PATCH 012/393] fix(dashboard): accept cited optional case facts
---
lineageweave/operations_case_analysis.py | 4 +++-
tests/test_operations_case_analysis.py | 25 ++++++++++++++++++++++++
2 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py
index 5e4c46fde..73ac9f7c9 100644
--- a/lineageweave/operations_case_analysis.py
+++ b/lineageweave/operations_case_analysis.py
@@ -156,7 +156,9 @@ def parse_operations_case_response(
any(not isinstance(code, str) or code not in FACT_TYPES for code in missing_fact_types)
or len(set(missing_fact_types)) != len(missing_fact_types)
or supported_types.intersection(missing_fact_types)
- or supported_types.union(missing_fact_types) != REQUIRED_FACT_TYPES[item["case_kind_code"]]
+ or not REQUIRED_FACT_TYPES[item["case_kind_code"]].issubset(
+ supported_types.union(missing_fact_types)
+ )
):
return None
cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts), evidence_source.post_id, evidence_source.input_sha256, tuple(missing_fact_types)))
diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py
index ebfdb20fa..3a5cce2b4 100644
--- a/tests/test_operations_case_analysis.py
+++ b/tests/test_operations_case_analysis.py
@@ -95,3 +95,28 @@ def test_requires_each_case_question_to_be_supported_or_explicitly_missing() ->
}]
payload[0]["missing_fact_type_codes"] = ["external_relation"]
assert parse_operations_case_response(json.dumps(payload), body) is None
+
+
+def test_accepts_grounded_nonrequired_fact_after_required_questions_are_complete() -> None:
+ """A cited optional fact must not invalidate complete required answers."""
+ body = "A public notice was published and assigned to the sales team."
+ payload = [{
+ "case_kind_code": "external_information",
+ "summary_text": "External notice",
+ "evidence_text": "A public notice was published",
+ "facts": [
+ {
+ "fact_type_code": "external_relation",
+ "value_text": "Sales opportunity",
+ "evidence_text": body,
+ },
+ {
+ "fact_type_code": "our_owner",
+ "value_text": "Sales team",
+ "evidence_text": "assigned to the sales team",
+ },
+ ],
+ "missing_fact_type_codes": [],
+ }]
+
+ assert parse_operations_case_response(json.dumps(payload), body) is not None
From 242b89e24a709b9f23ef133b4d45b1a54b8807ff Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Wed, 26 Aug 2026 00:35:59 +0900
Subject: [PATCH 013/393] test(load): exercise dashboard in authenticated k6
flow
---
docs/operability/http-concurrency-evidence.md | 4 ++--
scripts/k6_http_e2e.js | 11 +++++++----
tests/test_k6_http_e2e_contract.py | 4 +++-
3 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md
index ae3684142..1db0922a6 100644
--- a/docs/operability/http-concurrency-evidence.md
+++ b/docs/operability/http-concurrency-evidence.md
@@ -4,7 +4,7 @@ LineageWeave provides `scripts/k6_http_e2e.js` to measure the real Compose
HTTP boundary while a synthetic Global Ask job is queued or running. It logs
in through the seeded Keycloak realm, submits one non-identifying question to
`POST /api/ask`, then drives concurrent authenticated requests to posts,
-Event Lineage, and the Ask-status projection.
+Event Lineage, the evidence Dashboard, and the Ask-status projection.
This implements the measurement side of ADR 0204's resource-release decision:
provider work is asynchronous, so ordinary readers should remain observable
@@ -35,7 +35,7 @@ k6 reports observed request counts, failure rate, and duration distributions.
The custom metrics separate:
- `lineageweave_ask_enqueue_duration`: time to persist and acknowledge the job;
-- `lineageweave_read_duration{endpoint:posts|lineage}`: ordinary reader paths;
+- `lineageweave_read_duration{endpoint:posts|lineage|dashboard}`: ordinary reader paths;
- `lineageweave_ask_poll_duration`: owner-scoped status polling.
- `lineageweave_ask_state_observations{job_status:...}`: how many observations
occurred while the one queued job was queued, running, or settled.
diff --git a/scripts/k6_http_e2e.js b/scripts/k6_http_e2e.js
index 737b2e09f..d5fac6a14 100644
--- a/scripts/k6_http_e2e.js
+++ b/scripts/k6_http_e2e.js
@@ -46,6 +46,7 @@ function readBatch(token, askJobId) {
return http.batch([
["GET", `${backendUrl}/api/posts`, null, { ...params, tags: { endpoint: "posts" } }],
["GET", `${backendUrl}/api/lineage`, null, { ...params, tags: { endpoint: "lineage" } }],
+ ["GET", `${backendUrl}/api/dashboard`, null, { ...params, tags: { endpoint: "dashboard" } }],
[
"GET",
`${backendUrl}/api/ask/jobs/${askJobId}`,
@@ -80,13 +81,15 @@ export default function (data) {
readDuration.add(responses[0].timings.duration, { endpoint: "posts" });
readDuration.add(responses[1].timings.duration, { endpoint: "lineage" });
- askPollDuration.add(responses[2].timings.duration);
- if (responses[2].status === 200) {
+ readDuration.add(responses[2].timings.duration, { endpoint: "dashboard" });
+ askPollDuration.add(responses[3].timings.duration);
+ if (responses[3].status === 200) {
askStateObservations.add(1, {
- job_status: String(responses[2].json("job_status_code") || "unknown"),
+ job_status: String(responses[3].json("job_status_code") || "unknown"),
});
}
check(responses[0], { "posts read succeeds": (response) => response.status === 200 });
check(responses[1], { "lineage read succeeds": (response) => response.status === 200 });
- check(responses[2], { "Ask poll succeeds": (response) => response.status === 200 });
+ check(responses[2], { "dashboard read succeeds": (response) => response.status === 200 });
+ check(responses[3], { "Ask poll succeeds": (response) => response.status === 200 });
}
diff --git a/tests/test_k6_http_e2e_contract.py b/tests/test_k6_http_e2e_contract.py
index f77d8f596..a825bd629 100644
--- a/tests/test_k6_http_e2e_contract.py
+++ b/tests/test_k6_http_e2e_contract.py
@@ -11,4 +11,6 @@ def test_k6_harness_renews_expired_auth_and_discloses_job_state() -> None:
assert "responses.some((response) => response.status === 401)" in source
assert source.count("responses = readBatch(vuToken, data.askJobId)") == 2
assert "lineageweave_ask_state_observations" in source
- assert 'job_status: String(responses[2].json("job_status_code")' in source
+ assert 'job_status: String(responses[3].json("job_status_code")' in source
+ assert '["GET", `${backendUrl}/api/dashboard`' in source
+ assert 'endpoint: "dashboard"' in source
From fae1d576d4d442faeb963f07d3595ece914ad4ff Mon Sep 17 00:00:00 2001
From: seonghobae
Date: Wed, 26 Aug 2026 00:37:30 +0900
Subject: [PATCH 014/393] fix(dashboard): preserve event counts and external
scope
---
backend/app/main.py | 2 ++
backend/app/operations_dashboard.py | 26 ++++++++++++++++---
.../adr/0206-evidence-operations-dashboard.md | 9 +++++--
frontend/src/api.ts | 2 ++
.../components/OperationsDashboard.test.tsx | 13 ++++++++++
.../src/components/OperationsDashboard.tsx | 6 ++---
tests/test_operations_case_analysis.py | 21 +++++++++++++++
tests/test_operations_dashboard.py | 26 ++++++++++++++++---
8 files changed, 92 insertions(+), 13 deletions(-)
diff --git a/backend/app/main.py b/backend/app/main.py
index b53907977..8e2534017 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -776,6 +776,7 @@ async def read_me(
async def operations_dashboard(
period_start: date | None = Query(None),
period_end: date | None = Query(None),
+ external_only: bool = Query(False),
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
@@ -789,6 +790,7 @@ async def operations_dashboard(
account.process_unit_ids,
period_start,
period_end,
+ external_only,
)
except ValueError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index af610cf43..a1007bcc9 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -60,11 +60,12 @@ async def fetch_operations_dashboard(
process_unit_ids: tuple[str, ...] | list[str] = (),
period_start: date | None = None,
period_end: date | None = None,
+ external_only: bool = False,
) -> dict[str, Any]:
"""Return quantified cases and their persisted source evidence."""
if period_start and period_end and period_start > period_end:
raise ValueError("period_start must not be after period_end")
- args = (list(corporate_entity_ids), list(process_unit_ids), period_start, period_end)
+ args = (list(corporate_entity_ids), list(process_unit_ids), period_start, period_end, external_only)
visible = _visible_period_sql()
metrics = await conn.fetchrow(
f"""
@@ -72,13 +73,24 @@ async def fetch_operations_dashboard(
select post.post_id
from source_post post
where {visible}
+ and ($5::boolean is false or exists (
+ select 1
+ from operations_case_classification scoped_classification
+ where scoped_classification.post_id = post.post_id
+ and scoped_classification.case_kind_code = 'external_information'
+ ))
), classified as (
select classification.post_id, classification.case_kind_code
from operations_case_classification classification
join visible_post on visible_post.post_id = classification.post_id
)
select (select count(*) from visible_post) as total_post_count,
- (select count(*) from classified) as total_event_count,
+ (select count(*)
+ from post_summary_event summary_event
+ where exists (
+ select 1 from classified
+ where classified.post_id = summary_event.post_id
+ )) as total_event_count,
(select count(distinct post_id) from classified
where case_kind_code = 'external_information') as external_post_count,
(select count(*) from visible_post
@@ -107,7 +119,10 @@ async def fetch_operations_dashboard(
coalesce(post.event_occurred_at, post.created_at) as occurred_at,
coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)
as project_name,
- coalesce(project.project_names, array[]::text[]) as project_names
+ coalesce(project.project_names, array[]::text[]) as project_names,
+ (select count(*)::int
+ from post_summary_event summary_event
+ where summary_event.post_id = classification.post_id) as event_count
from operations_case_classification classification
join source_post post on post.post_id = classification.post_id
left join lateral (
@@ -131,6 +146,7 @@ async def fetch_operations_dashboard(
where names.project_name is not null
) project on true
where {visible}
+ and ($5::boolean is false or classification.case_kind_code = 'external_information')
order by coalesce(post.event_occurred_at, post.created_at) desc,
classification.post_id, classification.case_kind_code
""",
@@ -144,6 +160,7 @@ async def fetch_operations_dashboard(
from operations_case_fact fact
join source_post post on post.post_id = fact.post_id
where {visible}
+ and ($5::boolean is false or fact.case_kind_code = 'external_information')
order by fact.post_id, fact.case_kind_code, fact.fact_ordinal
""",
*args,
@@ -154,6 +171,7 @@ async def fetch_operations_dashboard(
from operations_case_missing_fact missing
join source_post post on post.post_id = missing.post_id
where {visible}
+ and ($5::boolean is false or missing.case_kind_code = 'external_information')
order by missing.post_id, missing.case_kind_code, missing.fact_type_code
""",
*args,
@@ -186,7 +204,7 @@ async def fetch_operations_dashboard(
for row in case_rows:
kind = row["case_kind_code"]
case_post_ids.setdefault(kind, set()).add(str(row["post_id"]))
- case_event_counts[kind] = case_event_counts.get(kind, 0) + 1
+ case_event_counts[kind] = case_event_counts.get(kind, 0) + int(row["event_count"])
return {
"period_label": _period_label(period_start, period_end),
"total_post_count": total,
diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
index ce6a8c2ee..3e10282d7 100644
--- a/docs/adr/0206-evidence-operations-dashboard.md
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -28,8 +28,11 @@ provenance.
2. Dashboard requests are bounded by an inclusive event-time period.
`source_post.event_occurred_at` is the primary clock and `created_at` is the
explicit fallback, matching ADR 0202. The response names that clock.
-3. Every count is authorization-filtered before aggregation. The API returns
- both event count and distinct post count; neither substitutes for the other.
+3. Every count is authorization-filtered before aggregation. Event count is
+ the number of persisted `post_summary_event` rows for the classified,
+ visible posts; post count is the distinct count of those posts. Neither
+ substitutes for the other, and no event is invented when a summary event
+ row is absent.
Analysis-pending and ingestion-failed post counts are disjoint: a failed
current job is shown as retryable failure, never hidden inside the pending
count or interpreted as a negative classification.
@@ -50,6 +53,8 @@ provenance.
visible posts in the same period. The stored `vom` source code is supplied
to the orchestrator as labeled evidence, but does not replace semantic
analysis. Zero total posts yields `0`.
+ The external destination passes an API scope so non-external counts and
+ case rows are excluded at the SQL boundary, not merely hidden in the UI.
7. Qualitative rows project only persisted evidence:
project names and evidence spans, source sales-pool code/name, summary
events, requester/processor action evidence, roles, and Event Lineage links.
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index c63436b9f..588d4ab3e 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -84,10 +84,12 @@ export function fetchOperationsDashboard(
accessToken: string,
periodStart = "",
periodEnd = "",
+ externalOnly = false,
): Promise {
const query = new URLSearchParams();
if (periodStart) query.set("period_start", periodStart);
if (periodEnd) query.set("period_end", periodEnd);
+ if (externalOnly) query.set("external_only", "true");
const suffix = query.size ? `?${query}` : "";
return backendFetch(`/api/dashboard${suffix}`, accessToken);
}
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index a3947e2a2..bc7c561da 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -52,6 +52,12 @@ describe("OperationsDashboardView", () => {
expect(screen.queryByText("분류 Event")).not.toBeInTheDocument();
});
+ it("does not label a scoped external count with a corpus-wide rate", () => {
+ render( undefined} />);
+ expect(screen.getByText("5건")).toBeInTheDocument();
+ expect(screen.queryByText("5건 · 25.0%")).not.toBeInTheDocument();
+ });
+
it("places multi-project evidence in every explicit journey and orders events oldest first", () => {
const later = { ...data.cases[0], post_id: "post-later", occurred_at: "2026-08-20T00:00:00Z", project_names: ["Synthetic Grid Upgrade", "Synthetic Relay Renewal"] };
const earlier = { ...data.cases[0], post_id: "post-earlier", occurred_at: "2026-08-01T00:00:00Z", project_names: ["Synthetic Grid Upgrade"] };
@@ -81,4 +87,11 @@ describe("OperationsDashboardView", () => {
expect(screen.getByLabelText("시작일")).toHaveValue("2026-08-01");
expect(screen.getByRole("status")).toHaveTextContent("불러오는 중");
});
+
+ it("requests the external scope at the API boundary", async () => {
+ vi.mocked(fetchOperationsDashboard).mockResolvedValue(data);
+ render( undefined} />);
+ await screen.findByText("5건");
+ expect(fetchOperationsDashboard).toHaveBeenCalledWith("synthetic-token", "", "", true);
+ });
});
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index cd739c0d6..39fd75757 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -20,11 +20,11 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP
let active = true;
setError(false);
setData(null);
- fetchOperationsDashboard(accessToken, ...submittedPeriod)
+ fetchOperationsDashboard(accessToken, ...submittedPeriod, externalOnly)
.then((value) => active && setData(value))
.catch(() => active && setError(true));
return () => { active = false; };
- }, [accessToken, submittedPeriod, retryCount]);
+ }, [accessToken, externalOnly, submittedPeriod, retryCount]);
return <>
) : null}
+ {!externalOnly ? (
+
+ ) : null}
{!externalOnly && journeys.length ? (
프로젝트 여정
@@ -129,3 +145,80 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
);
}
+
+/** Renders persisted ADR-0210 producer evidence without calculating a local score. */
+export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDashboardResponse; onOpenPost: (postId: string) => void }) {
+ const topicContext = data.topic_context;
+ return (
+
+
+ {topicContext.status_code === "unavailable" ? (
+
+
Topic model influence를 아직 표시할 수 없습니다.
+
{topicContext.next_action}
+
{topicContext.required_contracts.map((contract) => (
+ {contract.authority} · {contract.schema_version} · {contract.state_code === "persisted" ? "저장 완료" : "승인 결과 없음"}
+ ))}
+
+ ) : (
+ <>
+ {topicContext.next_action}
+
+ {topicContext.topics.map((topic) => (
+
+ Topic {topic.topic_index + 1} · {topic.activity_intervals.map((interval) => topicStateLabels[interval.state_code]).join(" / ")}
+
+ {topic.activity_intervals.map((interval) => (
+
+ {topicStateLabels[interval.state_code]} {interval.valid_from.slice(0, 10)} –{interval.valid_to.slice(0, 10)}
+
+ ))}
+
+ {topic.lineage_events.length ?
+ {topic.lineage_events.map((event) => {event.event_time.slice(0, 10)} · {event.event_code}{event.target_topic_index === null ? "" : ` → Topic ${event.target_topic_index + 1}`} )}
+ : null}
+ {topic.contexts.map((context) => (
+
+ {dimensionLabels[context.dimension_code]} · {context.context_label}
+
+
+ 값이 같으면 동점이며, 순번이나 임의 가중치를 추가하지 않습니다.
+ Event 발생일 상태 Model influence 불확실성 소속 근거 원문
+ {context.influences.map((influence) => (
+
+ {influence.occurred_at.slice(0, 10)}
+ {topicStateLabels[influence.topic_state_code]}
+ {influence.model_influence}
+ {influence.uncertainty_lower_value}–{influence.uncertainty_upper_value} · {influence.uncertainty_method_code}
+ weight {influence.membership_weight} · {influence.membership_evidence_sha256}
+ onOpenPost(influence.post_id)}>근거 글 열기
+
+ ))}
+
+
+
+ ))}
+
+ ))}
+
+ {topicContext.model_run ? (
+
+ 모형·실행 근거
+
+
TEPP run {topicContext.model_run.tepp_run_id}
+
TEPP snapshot {topicContext.model_run.tepp_snapshot_id}
+
Snapshot {topicContext.model_run.source_snapshot_sha256}
+
Knowledge cutoff {topicContext.model_run.knowledge_cutoff}
+
Posterior draws {topicContext.model_run.posterior_draw_count} · {topicContext.model_run.posterior_draw_set_id}
+
fast-mlsirm {topicContext.model_run.fast_mlsirm_version} · {topicContext.model_run.compute_backend_code} · {topicContext.model_run.precision_code}
+
+
+ ) : null}
+ >
+ )}
+
+ );
+}
diff --git a/migrations/0214_topic_context_influence_projection.sql b/migrations/0214_topic_context_influence_projection.sql
new file mode 100644
index 000000000..13daab657
--- /dev/null
+++ b/migrations/0214_topic_context_influence_projection.sql
@@ -0,0 +1,208 @@
+-- ADR 0210: normalized TEPP topic and fast-mlsirm influence projection.
+-- LineageWeave stores accepted producer evidence; it performs no estimator math.
+
+create table if not exists topic_model_run (
+ topic_model_run_id uuid primary key default uuid_generate_v4(),
+ analysis_run_id uuid not null unique references analysis_run (analysis_run_id),
+ tepp_run_id text not null unique check (length(btrim(tepp_run_id)) between 1 and 256),
+ tepp_snapshot_id text not null check (length(btrim(tepp_snapshot_id)) between 1 and 256),
+ tepp_schema_version text not null check (tepp_schema_version = 'tepp.topic_context_posterior.v1'),
+ tepp_model_contract_version text not null check (length(btrim(tepp_model_contract_version)) between 1 and 256),
+ tepp_artifact_sha256 text not null unique check (tepp_artifact_sha256 ~ '^[0-9a-f]{64}$'),
+ reported_source_snapshot_sha256 text not null check (reported_source_snapshot_sha256 ~ '^[0-9a-f]{64}$'),
+ reported_knowledge_cutoff timestamptz not null,
+ posterior_draw_set_id text not null check (length(btrim(posterior_draw_set_id)) between 1 and 256),
+ posterior_draw_count integer not null check (posterior_draw_count > 0),
+ topic_count integer not null check (topic_count >= 2),
+ inference_status_code text not null check (inference_status_code = 'posterior_topic_coordinates_not_importance'),
+ accepted_at timestamptz not null default now()
+);
+
+create table if not exists topic_definition (
+ topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade,
+ topic_index integer not null check (topic_index >= 0),
+ primary key (topic_model_run_id, topic_index)
+);
+
+create table if not exists topic_activity_interval (
+ topic_model_run_id uuid not null,
+ topic_index integer not null,
+ valid_from timestamptz not null,
+ valid_to timestamptz not null,
+ state_code text not null check (state_code in ('active', 'dormant', 'reactivated')),
+ primary key (topic_model_run_id, topic_index, valid_from),
+ foreign key (topic_model_run_id, topic_index)
+ references topic_definition (topic_model_run_id, topic_index) on delete cascade,
+ check (valid_from < valid_to)
+);
+
+create table if not exists topic_lineage_relation (
+ topic_model_run_id uuid not null,
+ relation_ordinal integer not null check (relation_ordinal >= 0),
+ event_code text not null check (event_code in ('birth', 'split', 'merge', 'retirement')),
+ source_topic_index integer not null,
+ target_topic_index integer,
+ event_time timestamptz not null,
+ evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'),
+ primary key (topic_model_run_id, relation_ordinal),
+ foreign key (topic_model_run_id, source_topic_index)
+ references topic_definition (topic_model_run_id, topic_index) on delete cascade,
+ foreign key (topic_model_run_id, target_topic_index)
+ references topic_definition (topic_model_run_id, topic_index) on delete cascade,
+ check (
+ (event_code in ('split', 'merge') and target_topic_index is not null)
+ or (event_code in ('birth', 'retirement') and target_topic_index is null)
+ )
+);
+
+create table if not exists topic_context_definition (
+ topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade,
+ dimension_code text not null check (dimension_code in ('business_unit', 'process_unit', 'team', 'person')),
+ context_id text not null check (length(btrim(context_id)) between 1 and 256),
+ context_label text not null check (length(btrim(context_label)) between 1 and 512),
+ primary key (topic_model_run_id, dimension_code, context_id)
+);
+
+create table if not exists topic_context_membership (
+ topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade,
+ topic_context_membership_id uuid not null default uuid_generate_v4(),
+ source_post_id uuid not null references source_post (post_id) on delete restrict,
+ dimension_code text not null check (dimension_code in ('business_unit', 'process_unit', 'team', 'person')),
+ context_id text not null check (length(btrim(context_id)) between 1 and 256),
+ membership_weight double precision not null check (
+ membership_weight > 0 and membership_weight < 'Infinity'::double precision
+ ),
+ valid_from timestamptz not null,
+ valid_to timestamptz not null,
+ evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'),
+ primary key (topic_model_run_id, topic_context_membership_id),
+ unique (topic_model_run_id, source_post_id, dimension_code, context_id, valid_from),
+ foreign key (topic_model_run_id, dimension_code, context_id)
+ references topic_context_definition (topic_model_run_id, dimension_code, context_id)
+ on delete cascade,
+ check (valid_from < valid_to)
+);
+
+create table if not exists topic_influence_run (
+ topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade,
+ topic_influence_run_id uuid not null default uuid_generate_v4(),
+ fast_mlsirm_schema_version text not null check (fast_mlsirm_schema_version = 'fast_mlsirm.topic_context_influence.v1'),
+ fast_mlsirm_version text not null check (length(btrim(fast_mlsirm_version)) between 1 and 128),
+ fast_mlsirm_code_revision text not null check (fast_mlsirm_code_revision ~ '^(?:[0-9a-f]{40}|[0-9a-f]{64})$'),
+ fast_mlsirm_artifact_sha256 text not null unique check (fast_mlsirm_artifact_sha256 ~ '^[0-9a-f]{64}$'),
+ reported_tepp_run_id text not null,
+ reported_snapshot_sha256 text not null check (reported_snapshot_sha256 ~ '^[0-9a-f]{64}$'),
+ reported_knowledge_cutoff timestamptz not null,
+ membership_fingerprint_sha256 text not null check (membership_fingerprint_sha256 ~ '^[0-9a-f]{64}$'),
+ compute_backend_code text not null check (compute_backend_code in ('rust_cpu', 'rust_gpu')),
+ precision_code text not null check (precision_code in ('f64', 'f32')),
+ posterior_draw_coverage integer not null check (posterior_draw_coverage > 0),
+ convergence_status_code text not null check (convergence_status_code = 'converged'),
+ identification_status_code text not null check (identification_status_code = 'identified'),
+ parity_status_code text not null check (parity_status_code = 'passed'),
+ accepted_at timestamptz not null default now(),
+ primary key (topic_model_run_id, topic_influence_run_id)
+);
+
+create table if not exists topic_post_context_influence (
+ topic_model_run_id uuid not null,
+ topic_influence_run_id uuid not null,
+ topic_context_membership_id uuid not null,
+ topic_index integer not null,
+ influence_value double precision not null check (
+ influence_value >= 0 and influence_value < 'Infinity'::double precision
+ ),
+ uncertainty_method_code text not null check (length(btrim(uncertainty_method_code)) between 1 and 128),
+ uncertainty_lower_value double precision not null check (
+ uncertainty_lower_value >= 0 and uncertainty_lower_value < 'Infinity'::double precision
+ ),
+ uncertainty_upper_value double precision not null check (
+ uncertainty_upper_value >= uncertainty_lower_value
+ and uncertainty_upper_value < 'Infinity'::double precision
+ ),
+ diagnostic_status_code text not null check (diagnostic_status_code = 'accepted'),
+ primary key (
+ topic_model_run_id,
+ topic_influence_run_id,
+ topic_context_membership_id,
+ topic_index
+ ),
+ foreign key (topic_model_run_id, topic_influence_run_id)
+ references topic_influence_run (topic_model_run_id, topic_influence_run_id) on delete cascade,
+ foreign key (topic_model_run_id, topic_context_membership_id)
+ references topic_context_membership (topic_model_run_id, topic_context_membership_id) on delete cascade,
+ foreign key (topic_model_run_id, topic_index)
+ references topic_definition (topic_model_run_id, topic_index) on delete cascade
+);
+
+create index if not exists topic_activity_interval_time_idx
+ on topic_activity_interval (valid_from, valid_to, topic_model_run_id, topic_index);
+create index if not exists topic_context_membership_post_time_idx
+ on topic_context_membership (source_post_id, valid_from, valid_to, topic_model_run_id);
+create index if not exists topic_post_context_influence_read_idx
+ on topic_post_context_influence (topic_model_run_id, topic_index, influence_value desc);
+
+create or replace function validate_topic_model_run_binding()
+returns trigger
+language plpgsql
+as $$
+declare
+ canonical_snapshot_sha256 text;
+ canonical_knowledge_cutoff timestamptz;
+ canonical_run_kind_code text;
+begin
+ select snapshot.snapshot_sha256, run.knowledge_cutoff, run.run_kind_code
+ into canonical_snapshot_sha256, canonical_knowledge_cutoff, canonical_run_kind_code
+ from analysis_run run
+ join analysis_source_snapshot snapshot
+ on snapshot.analysis_source_snapshot_id = run.analysis_source_snapshot_id
+ where run.analysis_run_id = new.analysis_run_id;
+
+ if canonical_run_kind_code is distinct from 'analysis_run_topic_lineage'
+ or new.reported_source_snapshot_sha256 is distinct from canonical_snapshot_sha256
+ or new.reported_knowledge_cutoff is distinct from canonical_knowledge_cutoff then
+ raise exception 'topic_model_run_provenance_binding_mismatch';
+ end if;
+ return new;
+end
+$$;
+
+drop trigger if exists topic_model_run_binding_check on topic_model_run;
+create trigger topic_model_run_binding_check
+before insert or update on topic_model_run
+for each row execute function validate_topic_model_run_binding();
+
+create or replace function validate_topic_influence_run_binding()
+returns trigger
+language plpgsql
+as $$
+declare
+ canonical_tepp_run_id text;
+ canonical_snapshot_sha256 text;
+ canonical_knowledge_cutoff timestamptz;
+ canonical_draw_count integer;
+begin
+ select model.tepp_run_id, snapshot.snapshot_sha256, run.knowledge_cutoff,
+ model.posterior_draw_count
+ into canonical_tepp_run_id, canonical_snapshot_sha256,
+ canonical_knowledge_cutoff, canonical_draw_count
+ from topic_model_run model
+ join analysis_run run on run.analysis_run_id = model.analysis_run_id
+ join analysis_source_snapshot snapshot
+ on snapshot.analysis_source_snapshot_id = run.analysis_source_snapshot_id
+ where model.topic_model_run_id = new.topic_model_run_id;
+
+ if new.reported_tepp_run_id is distinct from canonical_tepp_run_id
+ or new.reported_snapshot_sha256 is distinct from canonical_snapshot_sha256
+ or new.reported_knowledge_cutoff is distinct from canonical_knowledge_cutoff
+ or new.posterior_draw_coverage is distinct from canonical_draw_count then
+ raise exception 'topic_influence_provenance_binding_mismatch';
+ end if;
+ return new;
+end
+$$;
+
+drop trigger if exists topic_influence_run_binding_check on topic_influence_run;
+create trigger topic_influence_run_binding_check
+before insert or update on topic_influence_run
+for each row execute function validate_topic_influence_run_binding();
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index 25631b0ba..343df2b39 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -15,6 +15,11 @@ def __init__(self) -> None:
async def fetchrow(self, query: str, *args: object) -> dict[str, int]:
self.queries.append((query, args))
+ if "tepp_posterior_persisted" in query:
+ return {
+ "tepp_posterior_persisted": False,
+ "fast_mlsirm_influence_persisted": False,
+ }
return {
"total_post_count": 4,
"total_event_count": 3,
@@ -44,6 +49,8 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
"case_kind_code": "claim_investigation",
"fact_type_code": "sales_pool",
}]
+ if "from topic_post_context_influence influence" in query:
+ return []
return [
{
"post_id": "00000000-0000-0000-0000-000000000001",
@@ -135,7 +142,9 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None:
],
}
]
- assert len(conn.queries) == 4
+ assert result["topic_context"]["status_code"] == "unavailable"
+ assert result["topic_context"]["reason_code"] == "tepp_topic_posterior_not_persisted"
+ assert len(conn.queries) == 6
for query, args in conn.queries:
assert "visibility_code = 'public'" in query
assert "corporate_entity_id::text = any($1::text[])" in query
@@ -152,6 +161,98 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None:
assert "coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)" in case_query
+@pytest.mark.anyio
+async def test_dashboard_projects_exact_topic_influence_without_local_scoring() -> None:
+ """Accepted rows retain ties, membership evidence, and producer identity."""
+
+ class TopicConnection(_Connection):
+ async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
+ if "tepp_posterior_persisted" in query:
+ self.queries.append((query, args))
+ return {
+ "tepp_posterior_persisted": True,
+ "fast_mlsirm_influence_persisted": True,
+ }
+ return await super().fetchrow(query, *args)
+
+ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
+ if "from topic_post_context_influence influence" not in query:
+ return await super().fetch(query, *args)
+ self.queries.append((query, args))
+ common = {
+ "topic_model_run_id": "model-1",
+ "tepp_run_id": "tepp-1",
+ "tepp_snapshot_id": "tepp-snapshot-1",
+ "tepp_schema_version": "tepp.topic_context_posterior.v1",
+ "tepp_model_contract_version": "trsl-tm-1",
+ "tepp_artifact_sha256": "a" * 64,
+ "posterior_draw_set_id": "draws-1",
+ "posterior_draw_count": 32,
+ "topic_count": 2,
+ "source_snapshot_sha256": "b" * 64,
+ "knowledge_cutoff": datetime(2026, 8, 20, tzinfo=timezone.utc),
+ "topic_influence_run_id": "influence-1",
+ "fast_mlsirm_schema_version": "fast_mlsirm.topic_context_influence.v1",
+ "fast_mlsirm_version": "0.1.0",
+ "fast_mlsirm_code_revision": "c" * 40,
+ "fast_mlsirm_artifact_sha256": "d" * 64,
+ "compute_backend_code": "rust_gpu",
+ "precision_code": "f64",
+ "membership_fingerprint_sha256": "e" * 64,
+ "topic_index": 0,
+ "state_code": "reactivated",
+ "activity_valid_from": datetime(2026, 8, 1, tzinfo=timezone.utc),
+ "activity_valid_to": datetime(2026, 9, 1, tzinfo=timezone.utc),
+ "dimension_code": "team",
+ "context_id": "team-synthetic",
+ "context_label": "Synthetic Service Team",
+ "membership_weight": 0.5,
+ "membership_evidence_sha256": "f" * 64,
+ "occurred_at": datetime(2026, 8, 12, tzinfo=timezone.utc),
+ "influence_value": 4.25,
+ "uncertainty_method_code": "posterior_interval",
+ "uncertainty_lower_value": 3.5,
+ "uncertainty_upper_value": 5.0,
+ "diagnostic_status_code": "accepted",
+ "lineage_events": '[{"event_code":"birth","source_topic_index":0,"target_topic_index":null,"event_time":"2026-08-01T00:00:00+00:00","evidence_sha256":"' + "1" * 64 + '"}]',
+ }
+ return [
+ {**common, "source_post_id": "00000000-0000-0000-0000-000000000001"},
+ {
+ **common,
+ "source_post_id": "00000000-0000-0000-0000-000000000002",
+ "lineage_events": [{"event_code": "birth"}],
+ },
+ ]
+
+ result = await fetch_operations_dashboard(TopicConnection(), [])
+ topic_context = result["topic_context"]
+ assert topic_context["status_code"] == "accepted"
+ assert topic_context["model_run"]["compute_backend_code"] == "rust_gpu"
+ influences = topic_context["topics"][0]["contexts"][0]["influences"]
+ assert [item["model_influence"] for item in influences] == [4.25, 4.25]
+ assert influences[0]["membership_weight"] == 0.5
+ assert topic_context["topics"][0]["lineage_events"][0]["event_code"] == "birth"
+
+
+@pytest.mark.anyio
+async def test_dashboard_names_missing_fast_result_after_tepp_persistence() -> None:
+ """A persisted TEPP membership never becomes a fabricated influence value."""
+
+ class TeppOnlyConnection(_Connection):
+ async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
+ if "tepp_posterior_persisted" in query:
+ self.queries.append((query, args))
+ return {
+ "tepp_posterior_persisted": True,
+ "fast_mlsirm_influence_persisted": False,
+ }
+ return await super().fetchrow(query, *args)
+
+ result = await fetch_operations_dashboard(TeppOnlyConnection(), [])
+ assert result["topic_context"]["reason_code"] == "fast_mlsirm_influence_not_persisted"
+ assert result["topic_context"]["topics"] == []
+
@pytest.mark.anyio
async def test_external_scope_is_bound_in_every_dashboard_query() -> None:
"""The external destination restricts data at the API query boundary."""
@@ -171,6 +272,11 @@ async def test_dashboard_zero_denominator_and_invalid_period() -> None:
class EmptyConnection(_Connection):
async def fetchrow(self, query: str, *args: object) -> dict[str, int]:
self.queries.append((query, args))
+ if "tepp_posterior_persisted" in query:
+ return {
+ "tepp_posterior_persisted": False,
+ "fast_mlsirm_influence_persisted": False,
+ }
return dict.fromkeys(
("total_post_count", "total_event_count", "external_post_count", "pending_analysis_count", "failed_analysis_count"),
0,
@@ -183,6 +289,8 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
empty = await fetch_operations_dashboard(EmptyConnection(), [])
assert empty["external_percent"] == 0.0
assert all(metric["event_count"] == metric["post_count"] == 0 for metric in empty["case_metrics"])
+ assert (await fetch_operations_dashboard(EmptyConnection(), [], [], date(2026, 8, 1)))["period_label"] == "2026-08-01 이후 · Event 발생일"
+ assert (await fetch_operations_dashboard(EmptyConnection(), [], [], None, date(2026, 8, 31)))["period_label"] == "2026-08-31 이전 · Event 발생일"
with pytest.raises(ValueError, match="period_start"):
await fetch_operations_dashboard(
EmptyConnection(), [], [], date(2026, 9, 1), date(2026, 8, 31)
@@ -194,8 +302,19 @@ async def test_external_information_projects_a_typed_prov_o_relation() -> None:
"""A cited semantic target becomes RDF reification, never a KG alias."""
class ExternalConnection(_Connection):
+ async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
+ if "tepp_posterior_persisted" in query:
+ self.queries.append((query, args))
+ return {
+ "tepp_posterior_persisted": False,
+ "fast_mlsirm_influence_persisted": False,
+ }
+ return await super().fetchrow(query, *args)
+
async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
self.queries.append((query, args))
+ if "from topic_post_context_influence influence" in query:
+ return []
if "operations_case_fact fact" in query:
return [{
"post_id": "00000000-0000-0000-0000-000000000001",
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 0b4aaae69..5f7c730aa 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -95,6 +95,17 @@
_OPERATIONS_CASE_MISSING_MIGRATION = (
Path(__file__).resolve().parents[1] / "migrations" / "0211_operations_case_missing_fact.sql"
)
+_ANALYSIS_RUN_REGISTRY_MIGRATION = (
+ Path(__file__).resolve().parents[1] / "migrations" / "0018_analysis_run_registry.sql"
+)
+_TOPIC_CONTEXT_INFLUENCE_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0214_topic_context_influence_projection.sql"
+)
+_TOPIC_LINEAGE_KIND_MIGRATION = (
+ Path(__file__).resolve().parents[1] / "migrations" / "0131_analysis_run_topic_lineage_kind.sql"
+)
_OPERATIONS_EXTERNAL_RELATION_MIGRATION = (
Path(__file__).resolve().parents[1]
/ "migrations"
@@ -132,6 +143,8 @@ def schema_db():
try:
with conn.cursor() as cur:
cur.execute(_MIGRATION_PATH.read_text())
+ cur.execute(_ANALYSIS_RUN_REGISTRY_MIGRATION.read_text())
+ cur.execute(_TOPIC_LINEAGE_KIND_MIGRATION.read_text())
cur.execute(_PROJECT_MENTION_MIGRATION.read_text())
cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text())
cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text())
@@ -148,6 +161,7 @@ def schema_db():
cur.execute(_OPERATIONS_CASE_MIGRATION.read_text())
cur.execute(_OPERATIONS_CASE_EVIDENCE_MIGRATION.read_text())
cur.execute(_OPERATIONS_CASE_MISSING_MIGRATION.read_text())
+ cur.execute(_TOPIC_CONTEXT_INFLUENCE_MIGRATION.read_text())
cur.execute(_OPERATIONS_EXTERNAL_RELATION_MIGRATION.read_text())
conn.commit()
yield conn
@@ -207,10 +221,146 @@ def test_migration_applies_cleanly(schema_db) -> None:
"operations_case_classification",
"operations_case_fact",
"operations_case_missing_fact",
+ "topic_model_run",
+ "topic_definition",
+ "topic_activity_interval",
+ "topic_lineage_relation",
+ "topic_context_definition",
+ "topic_context_membership",
+ "topic_influence_run",
+ "topic_post_context_influence",
}
assert expected <= tables
+def test_topic_influence_schema_binds_exact_producer_provenance(schema_db) -> None:
+ """Accepted influence runs cannot cross a TEPP run, snapshot, or cutoff."""
+ with schema_db.cursor() as cur:
+ cur.execute(
+ """
+ select tgname
+ from pg_trigger
+ where tgname = 'topic_influence_run_binding_check'
+ and not tgisinternal
+ """
+ )
+ assert cur.fetchone() == ("topic_influence_run_binding_check",)
+ cur.execute(
+ """
+ select tgname
+ from pg_trigger
+ where tgname = 'topic_model_run_binding_check'
+ and not tgisinternal
+ """
+ )
+ assert cur.fetchone() == ("topic_model_run_binding_check",)
+ cur.execute(
+ """
+ select conname
+ from pg_constraint
+ where conrelid = 'topic_post_context_influence'::regclass
+ and contype = 'f'
+ """
+ )
+ foreign_keys = {row[0] for row in cur.fetchall()}
+ assert len(foreign_keys) == 3
+
+ account_id = uuid.uuid4()
+ snapshot_id = uuid.uuid4()
+ run_id = uuid.uuid4()
+ with schema_db.cursor() as cur:
+ cur.execute(
+ "insert into user_account (user_account_id, external_subject_id, display_name, email_address) values (%s, %s, %s, %s)",
+ (str(account_id), f"synthetic-{account_id}", "Synthetic Reviewer", f"synthetic-{account_id}@example.invalid"),
+ )
+ cur.execute(
+ """
+ insert into analysis_source_snapshot
+ (analysis_source_snapshot_id, snapshot_sha256, source_contract_version,
+ maximum_available_time, captured_at, created_at)
+ values (%s, %s, 'synthetic-v1', '2026-08-01T00:00:00Z',
+ '2026-08-02T00:00:00Z', '2026-08-03T00:00:00Z')
+ """,
+ (str(snapshot_id), "a" * 64),
+ )
+ cur.execute(
+ """
+ insert into analysis_run
+ (analysis_run_id, analysis_source_snapshot_id, run_kind_code,
+ requested_by_account_id, idempotency_key, knowledge_cutoff,
+ configuration_schema_version, configuration_sha256,
+ code_revision_sha, requested_at)
+ values (%s, %s, 'analysis_run_topic_lineage', %s, 'synthetic-topic-run',
+ '2026-08-01T00:00:00Z', 'synthetic-v1', %s, %s,
+ '2026-08-04T00:00:00Z')
+ """,
+ (str(run_id), str(snapshot_id), str(account_id), "b" * 64, "c" * 40),
+ )
+ cur.execute("savepoint topic_model_mismatch")
+ with pytest.raises(psycopg2.errors.RaiseException, match="topic_model_run_provenance_binding_mismatch"):
+ cur.execute(
+ """
+ insert into topic_model_run
+ (analysis_run_id, tepp_run_id, tepp_snapshot_id,
+ tepp_schema_version, tepp_model_contract_version,
+ tepp_artifact_sha256, reported_source_snapshot_sha256,
+ reported_knowledge_cutoff, posterior_draw_set_id,
+ posterior_draw_count, topic_count, inference_status_code)
+ values (%s, 'tepp-mismatch', 'snapshot-mismatch',
+ 'tepp.topic_context_posterior.v1', 'trsl-tm-v1', %s, %s,
+ '2026-08-01T00:00:00Z', 'draws-1', 8, 2,
+ 'posterior_topic_coordinates_not_importance')
+ """,
+ (str(run_id), "d" * 64, "e" * 64),
+ )
+ cur.execute("rollback to savepoint topic_model_mismatch")
+ cur.execute(
+ """
+ insert into topic_model_run
+ (analysis_run_id, tepp_run_id, tepp_snapshot_id,
+ tepp_schema_version, tepp_model_contract_version,
+ tepp_artifact_sha256, reported_source_snapshot_sha256,
+ reported_knowledge_cutoff, posterior_draw_set_id,
+ posterior_draw_count, topic_count, inference_status_code)
+ values (%s, 'tepp-accepted', 'snapshot-accepted',
+ 'tepp.topic_context_posterior.v1', 'trsl-tm-v1', %s, %s,
+ '2026-08-01T00:00:00Z', 'draws-1', 8, 2,
+ 'posterior_topic_coordinates_not_importance')
+ returning topic_model_run_id
+ """,
+ (str(run_id), "d" * 64, "a" * 64),
+ )
+ model_id = cur.fetchone()[0]
+ cur.execute("savepoint topic_influence_mismatch")
+ with pytest.raises(psycopg2.errors.RaiseException, match="topic_influence_provenance_binding_mismatch"):
+ cur.execute(
+ """
+ insert into topic_influence_run
+ (topic_model_run_id, fast_mlsirm_schema_version,
+ fast_mlsirm_version, fast_mlsirm_code_revision,
+ fast_mlsirm_artifact_sha256, reported_tepp_run_id,
+ reported_snapshot_sha256, reported_knowledge_cutoff,
+ membership_fingerprint_sha256, compute_backend_code,
+ precision_code, posterior_draw_coverage,
+ convergence_status_code, identification_status_code,
+ parity_status_code)
+ values (%s, 'fast_mlsirm.topic_context_influence.v1', '0.1.0',
+ %s, %s, 'different-tepp-run', %s,
+ '2026-08-01T00:00:00Z', %s, 'rust_cpu', 'f64', 8,
+ 'converged', 'identified', 'passed')
+ """,
+ (model_id, "f" * 40, "1" * 64, "a" * 64, "2" * 64),
+ )
+ cur.execute("rollback to savepoint topic_influence_mismatch")
+
+
+def test_topic_influence_projection_migration_replays(schema_db) -> None:
+ """The additive topic projection remains safe under sorted startup replay."""
+ with schema_db.cursor() as cur:
+ cur.execute(_TOPIC_CONTEXT_INFLUENCE_MIGRATION.read_text())
+ schema_db.commit()
+
+
def test_post_lineage_edge_requires_an_allen_interval_code(schema_db) -> None:
with schema_db.cursor() as cur:
cur.execute(
From a249efd65919b3b7e3542147110c2e6c7a5d66a9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 09:32:09 -0700
Subject: [PATCH 026/393] fix(dashboard): separate semantic relation labels
from values (#651)
Co-authored-by: Codex
---
frontend/src/App.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/App.css b/frontend/src/App.css
index cf34add79..f3f8fd08b 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1346,7 +1346,7 @@
}
.dashboard-case-card dl { margin: 0; }
-.dashboard-case-card dl div { display: grid; grid-template-columns: 8rem 1fr; padding: 0.5rem 0; border-top: 1px solid var(--color-border); }
+.dashboard-case-card dl div { display: grid; grid-template-columns: 8rem 1fr; gap: var(--space-control-gap); padding: 0.5rem 0; border-top: 1px solid var(--color-border); }
.dashboard-case-card dd { margin: 0; font-weight: 600; }
.dashboard-case-card button { margin-top: auto; }
From acf80790bebe14e330e91be15745de76a85365c7 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 01:43:36 +0900
Subject: [PATCH 027/393] perf(build): cache Rust-backed backend dependencies
---
backend/Dockerfile | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/backend/Dockerfile b/backend/Dockerfile
index eb6b86288..14851ad43 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -20,9 +20,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
-ENV PATH="/app/.venv/bin:/root/.cargo/bin:${PATH}"
+ENV PATH="/app/.venv/bin:/root/.cargo/bin:${PATH}" \
+ UV_LINK_MODE=copy
COPY pyproject.toml uv.lock README.md ./
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv sync --frozen --no-dev --extra backend --no-install-project --no-editable
COPY lineageweave ./lineageweave
COPY backend ./backend
# lineageweave/ontology.py resolves this path relative to itself
@@ -31,7 +34,8 @@ COPY docs/ontology ./docs/ontology
# Install exactly the committed universal lock. --no-editable prevents a
# runtime dependency on source-tree editability while retaining package data.
-RUN uv sync --frozen --no-dev --extra backend --no-editable \
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv sync --frozen --no-dev --extra backend --no-editable \
&& chown -R appuser:appuser /app
USER appuser
From 6f4211915aaa428769bbd9a09531320f2c129fda Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 09:45:14 -0700
Subject: [PATCH 028/393] feat(dashboard): measure observed case lifecycles
(#653)
* feat(dashboard): measure observed case lifecycles
* merge: preserve observed milestone dashboard contract
---------
Co-authored-by: Codex
---
ARCHITECTURE.md | 12 ++
backend/app/operations_case_ingestion.py | 34 ++-
backend/app/operations_dashboard.py | 197 ++++++++++++++---
backend/app/post_content_worker.py | 37 +++-
.../adr/0206-evidence-operations-dashboard.md | 22 +-
docs/product-requirements.md | 8 +-
docs/product-technical-gap-baseline.md | 6 +-
frontend/src/App.css | 22 ++
frontend/src/api.ts | 32 +++
.../OperationsDashboard.stories.tsx | 14 +-
.../components/OperationsDashboard.test.tsx | 12 ++
.../src/components/OperationsDashboard.tsx | 41 ++++
lineageweave/operations_case_analysis.py | 201 ++++++++++++++++--
migrations/0215_operations_case_milestone.sql | 36 ++++
tests/test_operations_case_analysis.py | 154 +++++++++++---
tests/test_operations_case_ingestion.py | 74 ++++++-
tests/test_operations_dashboard.py | 100 ++++++++-
tests/test_post_content_worker.py | 34 +++
tests/test_schema.py | 29 ++-
19 files changed, 957 insertions(+), 108 deletions(-)
create mode 100644 migrations/0215_operations_case_milestone.sql
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3a55015f5..af325c01c 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -751,6 +751,18 @@ HTML-wrapped, base64-image-embedded version of the existing
people through the live `/extract-keymen` endpoint
(`test_extract_keymen_normalizes_html_and_embedded_image_content`).
+## Evidence-operations lifecycle projection
+
+ADR 0206's Dashboard persists a semantic classification separately from its
+facts and observed milestones. `operations_case_milestone` binds a closed XES-
+style activity code to an exact evidence span, evidence-post digest, observed
+instant, and named source clock; `operations_case_missing_milestone` records an
+unsupported required endpoint without fabricating one. The Dashboard pairs
+only the three declared start/end definitions for claim investigation, rebid
+response, and handover. Both endpoints yield `end - start`; a cited start plus
+a missing end is open with nullable elapsed time. API projection rechecks
+current ABAC for focal and evidence posts before returning either span.
+
## Phase 6d: external search verification for Ontology relation inferences
The brief requires an external web/internal search agent to check the
diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py
index ea92c4f22..f6b1bbed8 100644
--- a/backend/app/operations_case_ingestion.py
+++ b/backend/app/operations_case_ingestion.py
@@ -36,7 +36,9 @@ async def persist_operations_cases(
) -> None:
"""Atomically replace one post's normalized case analysis."""
async with conn.transaction():
- await conn.execute("delete from operations_case_analysis where post_id = $1", post_id)
+ await conn.execute(
+ "delete from operations_case_analysis where post_id = $1", post_id
+ )
await conn.execute(
"insert into operations_case_analysis (post_id, source_body_sha256, orchestrator_session_id) values ($1, $2, $3)",
post_id,
@@ -64,5 +66,33 @@ async def persist_operations_cases(
if case.missing_fact_type_codes:
await conn.executemany(
"insert into operations_case_missing_fact (post_id, case_kind_code, fact_type_code) values ($1, $2, $3)",
- [(post_id, case.case_kind_code, code) for code in case.missing_fact_type_codes],
+ [
+ (post_id, case.case_kind_code, code)
+ for code in case.missing_fact_type_codes
+ ],
+ )
+ if case.milestones:
+ await conn.executemany(
+ "insert into operations_case_milestone (post_id, case_kind_code, milestone_type_code, evidence_text, evidence_post_id, evidence_input_sha256, observed_at, time_axis_code) values ($1, $2, $3, $4, $5, $6, $7, $8)",
+ [
+ (
+ post_id,
+ case.case_kind_code,
+ milestone.milestone_type_code,
+ milestone.evidence_text,
+ milestone.evidence_post_id,
+ milestone.evidence_input_sha256,
+ milestone.observed_at,
+ milestone.time_axis_code,
+ )
+ for milestone in case.milestones
+ ],
+ )
+ if case.missing_milestone_type_codes:
+ await conn.executemany(
+ "insert into operations_case_missing_milestone (post_id, case_kind_code, milestone_type_code) values ($1, $2, $3)",
+ [
+ (post_id, case.case_kind_code, code)
+ for code in case.missing_milestone_type_codes
+ ],
)
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index 74ceece09..4238ecc8d 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from datetime import date
+from datetime import date, datetime
import json
from typing import Any, Protocol
@@ -30,6 +30,19 @@
"issue_pattern": "반복 유형",
"improvement_action": "개선 조치",
}
+MILESTONE_TYPE_LABELS = {
+ "claim_received": "클레임 접수",
+ "cause_confirmed": "원인 확정",
+ "rebid_response_requested": "재입찰 대응 요청",
+ "rebid_decision_recorded": "재입찰 의사결정",
+ "handover_started": "인수인계 시작",
+ "handover_accepted": "인수 확인",
+}
+LIFECYCLE_DEFINITIONS = (
+ ("claim_investigation", "claim_investigation", "클레임 원인 규명", "claim_received", "cause_confirmed"),
+ ("rebid_response", "rebid_handover", "재입찰 대응", "rebid_response_requested", "rebid_decision_recorded"),
+ ("handover_gap", "rebid_handover", "인수인계 공백", "handover_started", "handover_accepted"),
+)
CASE_KIND_ONTOLOGY_CLASSES = {
"claim_investigation": str(LW.ClaimInvestigation),
"rebid_handover": str(LW.RebidHandover),
@@ -114,14 +127,21 @@ async def fetch(self, query: str, *args: object) -> list[Any]:
pass # pragma: no cover - structural Protocol member
-def _visible_period_sql(alias: str = "post") -> str:
- """Return the shared ABAC, eligibility, and event-clock predicate."""
+def _visible_scope_sql(alias: str = "post") -> str:
+ """Return the shared ABAC and source-eligibility predicate."""
return f"""
({alias}.visibility_code = 'public'
or ({alias}.corporate_entity_id::text = any($1::text[])
and (cardinality($2::text[]) = 0
or {alias}.process_unit_id::text = any($2::text[]))))
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias=alias)}
+ """
+
+
+def _visible_period_sql(alias: str = "post") -> str:
+ """Return the shared visibility predicate plus the requested event interval."""
+ return f"""
+ {_visible_scope_sql(alias)}
and ($3::date is null or (coalesce({alias}.event_occurred_at, {alias}.created_at)
at time zone 'Asia/Seoul')::date >= $3)
and ($4::date is null or (coalesce({alias}.event_occurred_at, {alias}.created_at)
@@ -142,6 +162,7 @@ async def fetch_operations_dashboard(
raise ValueError("period_start must not be after period_end")
args = (list(corporate_entity_ids), list(process_unit_ids), period_start, period_end, external_only)
visible = _visible_period_sql()
+ visible_evidence = _visible_scope_sql("evidence_post")
metrics = await conn.fetchrow(
f"""
with visible_post as (
@@ -158,6 +179,9 @@ async def fetch_operations_dashboard(
select classification.post_id, classification.case_kind_code
from operations_case_classification classification
join visible_post on visible_post.post_id = classification.post_id
+ join source_post evidence_post
+ on evidence_post.post_id = classification.evidence_post_id
+ where {visible_evidence}
)
select (select count(*) from visible_post) as total_post_count,
(select count(*)
@@ -200,6 +224,8 @@ async def fetch_operations_dashboard(
where summary_event.post_id = classification.post_id) as event_count
from operations_case_classification classification
join source_post post on post.post_id = classification.post_id
+ join source_post evidence_post
+ on evidence_post.post_id = classification.evidence_post_id
left join lateral (
select array_agg(names.project_name order by names.project_name) as project_names,
(
@@ -220,8 +246,9 @@ async def fetch_operations_dashboard(
) names
where names.project_name is not null
) project on true
- where {visible}
- and ($5::boolean is false or classification.case_kind_code = 'external_information')
+ where {visible}
+ and {visible_evidence}
+ and ($5::boolean is false or classification.case_kind_code = 'external_information')
order by coalesce(post.event_occurred_at, post.created_at) desc,
classification.post_id, classification.case_kind_code
""",
@@ -234,7 +261,9 @@ async def fetch_operations_dashboard(
fact.fact_ordinal, fact.relation_target_kind_code
from operations_case_fact fact
join source_post post on post.post_id = fact.post_id
+ join source_post evidence_post on evidence_post.post_id = fact.evidence_post_id
where {visible}
+ and {visible_evidence}
and ($5::boolean is false or fact.case_kind_code = 'external_information')
order by fact.post_id, fact.case_kind_code, fact.fact_ordinal
""",
@@ -246,11 +275,35 @@ async def fetch_operations_dashboard(
from operations_case_missing_fact missing
join source_post post on post.post_id = missing.post_id
where {visible}
+ and {visible_evidence}
and ($5::boolean is false or missing.case_kind_code = 'external_information')
order by missing.post_id, missing.case_kind_code, missing.fact_type_code
""",
*args,
)
+ milestone_rows = await conn.fetch(
+ f"""
+ select milestone.post_id, milestone.case_kind_code,
+ milestone.milestone_type_code, milestone.evidence_text,
+ milestone.evidence_post_id, milestone.observed_at,
+ milestone.time_axis_code, false as is_missing
+ from operations_case_milestone milestone
+ join source_post post on post.post_id = milestone.post_id
+ join source_post evidence_post on evidence_post.post_id = milestone.evidence_post_id
+ where {visible}
+ and {visible_evidence}
+ and ($5::boolean is false or milestone.case_kind_code = 'external_information')
+ union all
+ select missing.post_id, missing.case_kind_code,
+ missing.milestone_type_code, null, null, null, null, true
+ from operations_case_missing_milestone missing
+ join source_post post on post.post_id = missing.post_id
+ where {visible}
+ and ($5::boolean is false or missing.case_kind_code = 'external_information')
+ order by post_id, case_kind_code, milestone_type_code
+ """,
+ *args,
+ )
topic_context = (
{
"status_code": "not_applicable",
@@ -292,6 +345,30 @@ async def fetch_operations_dashboard(
"fact_type_label": FACT_TYPE_LABELS[row["fact_type_code"]],
}
)
+ milestones: dict[tuple[str, str], list[dict[str, Any]]] = {}
+ missing_milestones: dict[tuple[str, str], set[str]] = {}
+ for row in milestone_rows:
+ key = (str(row["post_id"]), row["case_kind_code"])
+ if row["is_missing"]:
+ missing_milestones.setdefault(key, set()).add(row["milestone_type_code"])
+ continue
+ milestones.setdefault(key, []).append(
+ {
+ "milestone_type_code": row["milestone_type_code"],
+ "milestone_type_label": MILESTONE_TYPE_LABELS[
+ row["milestone_type_code"]
+ ],
+ "evidence_text": row["evidence_text"],
+ "evidence_post_id": str(row["evidence_post_id"]),
+ "observed_at": row["observed_at"].isoformat(),
+ "time_axis_code": row["time_axis_code"],
+ "time_axis_label": (
+ "Event 발생일"
+ if row["time_axis_code"] == "event_occurred_at"
+ else "기록 생성일"
+ ),
+ }
+ )
total = int(metrics["total_post_count"])
external = int(metrics["external_post_count"])
case_post_ids: dict[str, set[str]] = {}
@@ -300,6 +377,50 @@ async def fetch_operations_dashboard(
kind = row["case_kind_code"]
case_post_ids.setdefault(kind, set()).add(str(row["post_id"]))
case_event_counts[kind] = case_event_counts.get(kind, 0) + int(row["event_count"])
+ projected_cases = []
+ lifecycle_metrics = {
+ lifecycle_code: {
+ "lifecycle_kind_code": lifecycle_code,
+ "lifecycle_kind_label": label,
+ "open_case_count": 0,
+ "resolved_case_count": 0,
+ "evidence_missing_case_count": 0,
+ }
+ for lifecycle_code, _kind, label, _start, _end in LIFECYCLE_DEFINITIONS
+ }
+ for row in case_rows:
+ key = (str(row["post_id"]), row["case_kind_code"])
+ case_milestones = milestones.get(key, [])
+ case_lifecycles = _project_lifecycles(
+ row["case_kind_code"], case_milestones, missing_milestones.get(key, set())
+ )
+ for lifecycle in case_lifecycles:
+ lifecycle_metrics[lifecycle["lifecycle_kind_code"]][
+ f"{lifecycle['status_code']}_case_count"
+ ] += 1
+ projected_cases.append(
+ {
+ "post_id": str(row["post_id"]),
+ "case_kind_code": row["case_kind_code"],
+ "case_kind_label": CASE_KIND_LABELS[row["case_kind_code"]],
+ "project_name": row["project_name"],
+ "project_names": list(row["project_names"]),
+ "summary_text": row["summary_text"],
+ "evidence_text": row["evidence_text"],
+ "evidence_post_id": str(row["evidence_post_id"]),
+ "ontology_class_iri": CASE_KIND_ONTOLOGY_CLASSES[row["case_kind_code"]],
+ "provenance_relation_iri": PROV_WAS_DERIVED_FROM,
+ "occurred_at": row["occurred_at"].isoformat(),
+ "facts": facts.get(key, []),
+ "missing_facts": missing_facts.get(key, []),
+ "milestones": case_milestones,
+ "lifecycles": case_lifecycles,
+ "semantic_projection": _operations_case_jsonld(
+ str(row["post_id"]), row["case_kind_code"],
+ str(row["evidence_post_id"]), facts.get(key, []),
+ ),
+ }
+ )
return {
"period_label": _period_label(period_start, period_end),
"total_post_count": total,
@@ -318,33 +439,51 @@ async def fetch_operations_dashboard(
for kind, label in CASE_KIND_LABELS.items()
],
"topic_context": topic_context,
- "cases": [
- {
- "post_id": str(row["post_id"]),
- "case_kind_code": row["case_kind_code"],
- "case_kind_label": CASE_KIND_LABELS[row["case_kind_code"]],
- "project_name": row["project_name"],
- "project_names": list(row["project_names"]),
- "summary_text": row["summary_text"],
- "evidence_text": row["evidence_text"],
- "evidence_post_id": str(row["evidence_post_id"]),
- "ontology_class_iri": CASE_KIND_ONTOLOGY_CLASSES[row["case_kind_code"]],
- "provenance_relation_iri": PROV_WAS_DERIVED_FROM,
- "occurred_at": row["occurred_at"].isoformat(),
- "facts": facts.get((str(row["post_id"]), row["case_kind_code"]), []),
- "missing_facts": missing_facts.get((str(row["post_id"]), row["case_kind_code"]), []),
- "semantic_projection": _operations_case_jsonld(
- str(row["post_id"]),
- row["case_kind_code"],
- str(row["evidence_post_id"]),
- facts.get((str(row["post_id"]), row["case_kind_code"]), []),
- ),
- }
- for row in case_rows
- ],
+ "lifecycle_metrics": list(lifecycle_metrics.values()),
+ "cases": projected_cases,
}
+def _project_lifecycles(
+ case_kind_code: str,
+ milestones: list[dict[str, Any]],
+ missing_milestones: set[str],
+) -> list[dict[str, Any]]:
+ """Pair observed endpoints and report exact elapsed time without thresholds."""
+ by_type = {value["milestone_type_code"]: value for value in milestones}
+ result = []
+ for lifecycle_code, required_kind, label, start_code, end_code in LIFECYCLE_DEFINITIONS:
+ if case_kind_code != required_kind:
+ continue
+ start = by_type.get(start_code)
+ end = by_type.get(end_code)
+ if start and end:
+ elapsed_seconds = int((datetime.fromisoformat(end["observed_at"]) - datetime.fromisoformat(start["observed_at"])).total_seconds())
+ status_code = "resolved"
+ next_action = "시작·종료 Event 근거를 열어 경과 시간을 검토하세요."
+ elif start and end_code in missing_milestones:
+ elapsed_seconds = None
+ status_code = "open"
+ next_action = f"{MILESTONE_TYPE_LABELS[end_code]} Event 근거를 연결하세요."
+ else:
+ elapsed_seconds = None
+ status_code = "evidence_missing"
+ next_action = f"{MILESTONE_TYPE_LABELS[start_code]} Event 근거를 연결하세요."
+ result.append({
+ "lifecycle_kind_code": lifecycle_code,
+ "lifecycle_kind_label": label,
+ "status_code": status_code,
+ "status_label": {"resolved": "종료 확인", "open": "진행 중", "evidence_missing": "측정 근거 부족"}[status_code],
+ "started_at": start["observed_at"] if start else None,
+ "resolved_at": end["observed_at"] if end else None,
+ "elapsed_seconds": elapsed_seconds,
+ "start_milestone": start,
+ "end_milestone": end,
+ "next_action_text": next_action,
+ })
+ return result
+
+
async def _fetch_topic_context_dashboard(
conn: _Connection,
visible_post_sql: str,
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 825c35b77..fd7dd8d2f 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -46,7 +46,9 @@
_INCOMPLETE_FAILURE_CODE = "post_content_ingestion_incomplete"
_ATTEMPT_LIMIT_FAILURE_CODE = "post_content_ingestion_attempt_limit"
_SOURCE_BODY_MISSING_FAILURE_CODE = "post_content_source_body_missing"
-_UNEXPECTED_FAILURE_DETAIL = "post-content provider operation failed; retry the ingestion job"
+_UNEXPECTED_FAILURE_DETAIL = (
+ "post-content provider operation failed; retry the ingestion job"
+)
async def _operations_evidence_sources(
@@ -68,6 +70,22 @@ def can_see(row: asyncpg.Record) -> bool:
async with pool.acquire() as conn:
sources = await gather_chat_sources(conn, post_id, can_see, vision_client)
+ if not sources:
+ return ()
+ source_times = {
+ str(row["post_id"]): (
+ row["observed_at"],
+ "event_occurred_at"
+ if row["event_occurred_at"] is not None
+ else "created_at",
+ )
+ for row in await conn.fetch(
+ "select post_id, event_occurred_at, "
+ "coalesce(event_occurred_at, created_at) as observed_at "
+ "from source_post where post_id = any($1::uuid[])",
+ [source.post_id for source in sources],
+ )
+ }
return tuple(
OperationsEvidenceSource(
source.post_id,
@@ -78,6 +96,8 @@ def can_see(row: asyncpg.Record) -> bool:
if source.evidence_facts
else ""
),
+ source_times[source.post_id][0],
+ source_times[source.post_id][1],
)
for source in sources
)
@@ -312,7 +332,9 @@ async def process_post_content_job(
structure_client = structure_factory()
with use_llm_metadata(metadata):
vision_client = vision_factory()
- normalized = await asyncio.to_thread(normalize_post_body, raw_body, vision_client)
+ normalized = await asyncio.to_thread(
+ normalize_post_body, raw_body, vision_client
+ )
async with pool.acquire() as conn:
await persist_post_content(
conn,
@@ -360,7 +382,9 @@ async def process_post_content_job(
complete = await post_content_is_complete(
conn,
post_id,
- embedding_model_code=getattr(embedding_client, "resolved_model", None),
+ embedding_model_code=getattr(
+ embedding_client, "resolved_model", None
+ ),
require_embedding=require_orchestrator_evidence,
require_structure=require_orchestrator_evidence,
)
@@ -410,7 +434,9 @@ async def consume_post_content_stream_once(
from there on the next poll.
"""
try:
- batches = await client.xread({POST_CONTENT_STREAM_KEY: last_id}, count=10, block=1000)
+ batches = await client.xread(
+ {POST_CONTENT_STREAM_KEY: last_id}, count=10, block=1000
+ )
except Exception:
# Keep idle polls silent, but retain a diagnostic span for broker failures.
with traced(
@@ -480,6 +506,7 @@ async def run_post_content_worker(
)
except (redis.RedisError, OSError) as exc:
_logger.warning(
- "post-content Valkey poll failed; retrying (error_type=%s)", type(exc).__name__
+ "post-content Valkey poll failed; retrying (error_type=%s)",
+ type(exc).__name__,
)
await asyncio.sleep(_BROKER_RECOVERY_DELAY_SECONDS)
diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
index 21d923fd3..cacb982e2 100644
--- a/docs/adr/0206-evidence-operations-dashboard.md
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -137,8 +137,28 @@ treated as a negative case.
responses, source-digest invalidation, and unavailable orchestrator states.
- Backend integration tests cover ABAC filtering, event-time fallback, event
versus post counts, external-information percentage, multi-project
- membership, and explicit missing facts.
+ membership, explicit missing facts, observed lifecycle endpoints, exact
+ elapsed duration, open cases with nullable elapsed time, reversed endpoint
+ rejection, and evidence-post authorization.
- Frontend tests cover period submission, navigation, empty/error states,
evidence links, keyboard semantics, and non-color status copy.
- Storybook interaction tests and authenticated browser screenshots audit the
rendered desktop and narrow layouts.
+
+## References
+
+Institute of Electrical and Electronics Engineers. (2023). *IEEE standard for
+eXtensible Event Stream (XES) for achieving interoperability in event logs and
+event streams* (IEEE Std 1849-2023). IEEE Standards Association.
+https://standards.ieee.org/ieee/1849/10907/
+
+van der Aalst, W. M. P., Adriansyah, A., de Medeiros, A. K. A., Arcieri, F.,
+Baier, T., Blickle, T., Bose, J. C., van den Brand, P., Brandtjen, R., Buijs,
+J., Burattin, A., Carmona, J., Castellanos, M., Claes, J., Cook, J., Costantini,
+N., Curbera, F., Damiani, E., de Leoni, M., ... Wynn, M. (2012). Process mining
+manifesto. In F. Daniel, K. Barkaoui, & S. Dustdar (Eds.), *Business process
+management workshops* (pp. 169–194). Springer.
+https://doi.org/10.1007/978-3-642-28108-2_19
+
+World Wide Web Consortium. (2022). *Time ontology in OWL*.
+https://www.w3.org/TR/owl-time/
diff --git a/docs/product-requirements.md b/docs/product-requirements.md
index 75cba0410..36e5fcb44 100644
--- a/docs/product-requirements.md
+++ b/docs/product-requirements.md
@@ -83,12 +83,18 @@ content never becomes an external query or citation.
- Show persisted operational cases, actions, commitments, delivery status,
and similar-VOC evidence with extractive citations.
+- For claim investigation, rebid response, and handover, persist closed-vocabulary
+ milestones only when an authorized source span supports them. Report
+ open/resolved/evidence-missing counts and exact elapsed time only between two
+ observed endpoints; never invent an endpoint or delay threshold.
- Preserve controls during loading and retry; discard responses from an
earlier navigation scope.
- Distinguish pending, unavailable, failed, incomplete, and succeeded states.
Acceptance: each state tells the user the next valid action and never displays
-stale evidence from a previously opened post.
+stale evidence from a previously opened post. An open lifecycle has a cited
+start, a missing end, and nullable elapsed time; a resolved lifecycle links both
+endpoint sources and names the source clock used for each instant.
### PRD-FR-6 — Measurement boundary
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 5ed4f5387..098b2b031 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -8,8 +8,8 @@
| Requirement | Evidence contract | Delivery state |
|---|---|---|
-| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification with cited spans; Event Lineage context | Candidate API/UI reports separate per-kind Event and distinct-post counts; every required answer is cited or stored as an explicit missing fact with a collection action; authenticated runtime acceptance pending |
-| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus persisted summary actions/roles | Candidate API/UI reports separate per-kind Event and distinct-post counts; every required answer is cited or stored as an explicit missing fact; corpus backfill pending |
+| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification and `claim_received` → `cause_confirmed` milestones with cited spans and observed source clocks | Stacked candidate reports open/resolved/evidence-missing counts and exact elapsed time only for paired observed endpoints; every required answer and endpoint is cited or explicitly missing; authenticated runtime acceptance and corpus re-analysis pending |
+| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated runtime acceptance and corpus re-analysis pending |
| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated runtime acceptance pending |
| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated runtime acceptance pending |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
@@ -377,7 +377,7 @@ this file per §3.5 of the prior snapshot).
| #272 | Verify Global Ask KG/ontology/semantic claims with public SearXNG evidence | Ask stack |
| #274 | Persist and explain Event Lineage channel evidence | #387 |
| #277 | TEPP: persist accepted receipts, poll completed results, keep measurement authority distinct | #468, #417 |
-| #280 | Full project-lifecycle history and handover intervals | Tracked with issue #284; no active delivery PR confirmed |
+| #280 | Full project-lifecycle history and handover intervals | The current stacked candidate covers observed claim, rebid-response, and handover endpoint pairs; cross-record business-case identity remains unavailable unless an explicit source identifier is persisted, so project/similarity proximity is not used as a substitute |
| #284 | Authoritative lifecycle ingestion and idempotent reconciliation | No active delivery PR confirmed |
| #289 | Activate the optional lineage LLM channel through a bounded asynchronous rebuild | #434 |
| #336 | Replace pseudo-CalDAV feed with a Naruon-owned calendar projection | Contract on `main` (#355); operator consume wiring in historical branch `feat/naruon-calendar-buyer-wiring-v2170` |
diff --git a/frontend/src/App.css b/frontend/src/App.css
index f3f8fd08b..123d01254 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1301,6 +1301,14 @@
.dashboard-metrics dd { margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: 700; }
.dashboard-case-metrics .dashboard-metrics { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+.dashboard-lifecycle-summary { margin: 1.5rem 0; }
+.dashboard-lifecycle-summary > p { color: var(--color-text); }
+.dashboard-lifecycle-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border: 1px solid var(--color-border); }
+.dashboard-lifecycle-metrics > div { padding: 1rem; border-right: 1px solid var(--color-border); }
+.dashboard-lifecycle-metrics > div:last-child { border-right: 0; }
+.dashboard-lifecycle-metrics dt { font-weight: 700; }
+.dashboard-lifecycle-metrics dd { margin: 0.5rem 0 0; color: var(--color-text); }
+
.dashboard-case-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
@@ -1345,6 +1353,15 @@
border-left: 3px solid var(--color-dashboard-positive);
}
+.dashboard-case-lifecycles { display: grid; gap: 1rem; }
+.dashboard-lifecycle-row { padding: 1rem; border: 1px solid var(--color-border); background: var(--color-background); }
+.dashboard-lifecycle-row header { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
+.dashboard-lifecycle-row h4, .dashboard-lifecycle-row p { margin: 0; }
+.dashboard-lifecycle-row ol { display: grid; gap: 0.5rem; margin: 1rem 0; padding: 0; list-style: none; }
+.dashboard-lifecycle-row li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.5rem; border-top: 1px solid var(--color-border); padding-top: 0.5rem; }
+.dashboard-lifecycle-row time { grid-column: 1 / -1; color: var(--color-text); font-variant-numeric: tabular-nums; }
+.dashboard-next-action { font-weight: 700; }
+
.dashboard-case-card dl { margin: 0; }
.dashboard-case-card dl div { display: grid; grid-template-columns: 8rem 1fr; gap: var(--space-control-gap); padding: 0.5rem 0; border-top: 1px solid var(--color-border); }
.dashboard-case-card dd { margin: 0; font-weight: 600; }
@@ -1461,6 +1478,11 @@
.operations-dashboard-heading { align-items: start; flex-direction: column; }
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-case-metrics .dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .dashboard-lifecycle-metrics { grid-template-columns: 1fr; }
+ .dashboard-lifecycle-metrics > div { border-right: 0; border-bottom: 1px solid var(--color-border); }
+ .dashboard-lifecycle-metrics > div:last-child { border-bottom: 0; }
+ .dashboard-lifecycle-row li { grid-template-columns: 1fr; }
+ .dashboard-lifecycle-row li button { width: 100%; }
.dashboard-case-grid { grid-template-columns: 1fr; }
.dashboard-topic-context > header { align-items: start; flex-direction: column; }
.dashboard-topic-provenance dl div { grid-template-columns: 1fr; gap: 0.25rem; }
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 1eb85a5d5..fc76a6b6d 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -55,6 +55,29 @@ export interface OperationsDashboardFact {
relation_predicate_iri?: string;
}
+export interface OperationsDashboardMilestone {
+ milestone_type_code: string;
+ milestone_type_label: string;
+ evidence_text: string;
+ evidence_post_id: string;
+ observed_at: string;
+ time_axis_code: "event_occurred_at" | "created_at";
+ time_axis_label: string;
+}
+
+export interface OperationsDashboardLifecycle {
+ lifecycle_kind_code: string;
+ lifecycle_kind_label: string;
+ status_code: "resolved" | "open" | "evidence_missing";
+ status_label: string;
+ started_at: string | null;
+ resolved_at: string | null;
+ elapsed_seconds: number | null;
+ start_milestone: OperationsDashboardMilestone | null;
+ end_milestone: OperationsDashboardMilestone | null;
+ next_action_text: string;
+}
+
export interface OperationsDashboardCase {
post_id: string;
case_kind_code: string;
@@ -67,6 +90,8 @@ export interface OperationsDashboardCase {
occurred_at: string;
facts: OperationsDashboardFact[];
missing_facts: Array<{ fact_type_code: string; fact_type_label: string }>;
+ milestones: OperationsDashboardMilestone[];
+ lifecycles: OperationsDashboardLifecycle[];
ontology_class_iri?: string;
provenance_relation_iri?: string;
semantic_projection?: Record;
@@ -86,6 +111,13 @@ export interface OperationsDashboardResponse {
event_count: number;
post_count: number;
}>;
+ lifecycle_metrics: Array<{
+ lifecycle_kind_code: string;
+ lifecycle_kind_label: string;
+ open_case_count: number;
+ resolved_case_count: number;
+ evidence_missing_case_count: number;
+ }>;
topic_context: TopicContextDashboard;
cases: OperationsDashboardCase[];
}
diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx
index 6d813019f..affe0f78e 100644
--- a/frontend/src/components/OperationsDashboard.stories.tsx
+++ b/frontend/src/components/OperationsDashboard.stories.tsx
@@ -18,6 +18,11 @@ export const EvidenceReady: Story = {
{ case_kind_code: "external_information", case_kind_label: "발주 공고 · 시장 동향", event_count: 9, post_count: 9 },
{ case_kind_code: "repeat_issue", case_kind_label: "반복 이슈", event_count: 2, post_count: 2 },
],
+ lifecycle_metrics: [
+ { lifecycle_kind_code: "claim_investigation", lifecycle_kind_label: "클레임 원인 규명", open_case_count: 1, resolved_case_count: 0, evidence_missing_case_count: 0 },
+ { lifecycle_kind_code: "rebid_response", lifecycle_kind_label: "재입찰 대응", open_case_count: 0, resolved_case_count: 1, evidence_missing_case_count: 0 },
+ { lifecycle_kind_code: "handover_gap", lifecycle_kind_label: "인수인계 공백", open_case_count: 0, resolved_case_count: 0, evidence_missing_case_count: 1 },
+ ],
topic_context: {
status_code: "unavailable", reason_code: "tepp_topic_posterior_not_persisted",
next_action: "TEPP posterior topic 계약 결과를 먼저 완료하세요.", model_run: null, topics: [],
@@ -28,10 +33,10 @@ export const EvidenceReady: Story = {
},
failed_analysis_count: 0,
cases: [
- { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }], missing_facts: [{ fact_type_code: "order", fact_type_label: "발생 수주" }, { fact_type_code: "specification_change", fact_type_label: "사양 변경" }, { fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }] },
- { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }], missing_facts: [{ fact_type_code: "discussion", fact_type_label: "협의 내용" }, { fact_type_code: "counterparty", fact_type_label: "협의 상대" }, { fact_type_code: "our_owner", fact_type_label: "우리측 담당자" }] },
- { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "external_relation", fact_type_label: "업무 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3", relation_target_kind_code: "project", relation_target_kind_label: "프로젝트" }], missing_facts: [] },
- { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }], missing_facts: [{ fact_type_code: "issue_pattern", fact_type_label: "반복 유형" }] },
+ { post_id: "synthetic-post-1", case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 역추적", project_name: "Synthetic Transformer Renewal", summary_text: "사양 변경 이후 원인 수주와 Pool을 확인", evidence_text: "Revision B originated in order SO-100 from pool SP-20.", evidence_post_id: "synthetic-post-1", occurred_at: "2026-08-04T00:00:00Z", facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "SO-100 · SP-20", evidence_text: "order SO-100 from pool SP-20", evidence_post_id: "synthetic-post-1" }], missing_facts: [{ fact_type_code: "order", fact_type_label: "발생 수주" }, { fact_type_code: "specification_change", fact_type_label: "사양 변경" }, { fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }], milestones: [], lifecycles: [] },
+ { post_id: "synthetic-post-2", case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", project_name: "Synthetic Transformer Renewal", summary_text: "담당자 교체 전 협의와 후속 결정을 연결", evidence_text: "The account owner and design lead agreed to submit the revised proposal.", evidence_post_id: "synthetic-post-2", occurred_at: "2026-08-11T00:00:00Z", facts: [{ fact_type_code: "decision", fact_type_label: "이어진 결정", value_text: "수정 제안 제출", evidence_text: "submit the revised proposal", evidence_post_id: "synthetic-post-2" }], missing_facts: [{ fact_type_code: "discussion", fact_type_label: "협의 내용" }, { fact_type_code: "counterparty", fact_type_label: "협의 상대" }, { fact_type_code: "our_owner", fact_type_label: "우리측 담당자" }], milestones: [], lifecycles: [] },
+ { post_id: "synthetic-post-3", case_kind_code: "external_information", case_kind_label: "외부 정보", project_name: "Synthetic Transformer Renewal", summary_text: "시장 공고를 영업 기회와 연결", evidence_text: "The public procurement notice opened on August 15.", evidence_post_id: "synthetic-post-3", occurred_at: "2026-08-15T00:00:00Z", facts: [{ fact_type_code: "external_relation", fact_type_label: "업무 관계", value_text: "갱신 제안 준비", evidence_text: "procurement notice", evidence_post_id: "synthetic-post-3", relation_target_kind_code: "project", relation_target_kind_label: "프로젝트" }], missing_facts: [], milestones: [], lifecycles: [] },
+ { post_id: "synthetic-post-4", case_kind_code: "repeat_issue", case_kind_label: "반복 이슈 반영", project_name: "Synthetic Transformer Renewal", summary_text: "동일 유형 이슈를 설계 개선으로 환류", evidence_text: "The same enclosure issue recurred after Revision B.", evidence_post_id: "synthetic-post-4", occurred_at: "2026-08-18T00:00:00Z", facts: [{ fact_type_code: "improvement_action", fact_type_label: "개선 과제", value_text: "표준 사양 개정", evidence_text: "Update the standard enclosure specification.", evidence_post_id: "synthetic-post-4" }], missing_facts: [{ fact_type_code: "issue_pattern", fact_type_label: "반복 유형" }], milestones: [], lifecycles: [] },
],
},
onOpenPost: () => undefined,
@@ -40,6 +45,7 @@ export const EvidenceReady: Story = {
const canvas = within(canvasElement);
await expect(canvas.getByText("9건 · 22.5%")).toBeInTheDocument();
await expect(canvas.getByText("7 Event · 5글")).toBeVisible();
+ await expect(canvas.getByText("3일 3시간 30분 0초")).toBeVisible();
await expect(canvas.getAllByRole("button", { name: "분류 근거 글 열기" })[0]).toBeVisible();
},
};
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index 9328c1235..dbb9bb5d3 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -21,6 +21,9 @@ const data: OperationsDashboardResponse = {
{ case_kind_code: "claim_investigation", case_kind_label: "클레임 원인 규명", event_count: 3, post_count: 2 },
{ case_kind_code: "rebid_handover", case_kind_label: "재입찰 · 인수인계", event_count: 2, post_count: 2 },
],
+ lifecycle_metrics: [
+ { lifecycle_kind_code: "claim_investigation", lifecycle_kind_label: "클레임 원인 규명", open_case_count: 1, resolved_case_count: 0, evidence_missing_case_count: 0 },
+ ],
topic_context: {
status_code: "unavailable",
reason_code: "tepp_topic_posterior_not_persisted",
@@ -37,6 +40,11 @@ const data: OperationsDashboardResponse = {
project_name: "Synthetic Grid Upgrade", summary_text: "사양 변경 이후 원인 수주를 확인했습니다.", evidence_text: "Revision B changed the enclosure.", evidence_post_id: "evidence-post-1", occurred_at: "2026-08-12T00:00:00Z",
facts: [{ fact_type_code: "originating_order", fact_type_label: "원인 수주", value_text: "ORDER-100", evidence_text: "Original order ORDER-100", evidence_post_id: "evidence-post-2" }],
missing_facts: [{ fact_type_code: "sales_pool", fact_type_label: "수주 Pool" }],
+ milestones: [
+ { milestone_type_code: "claim_received", milestone_type_label: "클레임 접수", evidence_text: "Claim received", evidence_post_id: "evidence-post-1", observed_at: "2026-08-01T09:00:00Z", time_axis_code: "event_occurred_at", time_axis_label: "Event 발생일" },
+ { milestone_type_code: "cause_confirmed", milestone_type_label: "원인 확정", evidence_text: "Cause confirmed", evidence_post_id: "evidence-post-2", observed_at: "2026-08-03T12:30:00Z", time_axis_code: "created_at", time_axis_label: "기록 생성일" },
+ ],
+ lifecycles: [{ lifecycle_kind_code: "claim_investigation", lifecycle_kind_label: "클레임 원인 규명", status_code: "resolved", status_label: "종료 확인", started_at: "2026-08-01T09:00:00Z", resolved_at: "2026-08-03T12:30:00Z", elapsed_seconds: 185400, start_milestone: { milestone_type_code: "claim_received", milestone_type_label: "클레임 접수", evidence_text: "Claim received", evidence_post_id: "evidence-post-1", observed_at: "2026-08-01T09:00:00Z", time_axis_code: "event_occurred_at", time_axis_label: "Event 발생일" }, end_milestone: { milestone_type_code: "cause_confirmed", milestone_type_label: "원인 확정", evidence_text: "Cause confirmed", evidence_post_id: "evidence-post-2", observed_at: "2026-08-03T12:30:00Z", time_axis_code: "created_at", time_axis_label: "기록 생성일" }, next_action_text: "시작·종료 Event 근거를 열어 경과 시간을 검토하세요." }],
}],
};
@@ -48,10 +56,14 @@ describe("OperationsDashboardView", () => {
expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument();
expect(screen.getByText("원인 수주")).toBeInTheDocument();
expect(screen.getByText(/수주 Pool: 권한 범위 내 근거가 없습니다/)).toBeInTheDocument();
+ expect(screen.getByText("2일 3시간 30분 0초")).toBeInTheDocument();
+ expect(screen.getByText(/진행 중 1건 · 종료 확인 0건/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" }));
expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1");
await userEvent.click(screen.getByRole("button", { name: "원인 수주 근거 열기" }));
expect(onOpenPost).toHaveBeenCalledWith("evidence-post-2");
+ await userEvent.click(screen.getByRole("button", { name: "클레임 접수 근거 열기" }));
+ expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1");
});
it("shows an actionable empty external-information state", () => {
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index e36adfdbf..f7c93dacb 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -1,6 +1,13 @@
import { useEffect, useState } from "react";
import { fetchOperationsDashboard, type OperationsDashboardResponse } from "../api";
+function formatElapsed(seconds: number): string {
+ const days = Math.floor(seconds / 86_400);
+ const hours = Math.floor((seconds % 86_400) / 3_600);
+ const minutes = Math.floor((seconds % 3_600) / 60);
+ return `${days}일 ${hours}시간 ${minutes}분 ${seconds % 60}초`;
+}
+
const dimensionLabels = {
business_unit: "사업부",
process_unit: "PU",
@@ -98,6 +105,20 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
) : null}
+ {!externalOnly ? (
+
+ 관측된 처리 구간
+ 임의 지연 기준 없이, 시작·종료 Event가 모두 확인된 구간만 경과 시간을 계산합니다.
+
+ {data.lifecycle_metrics.map((metric) => (
+
+
{metric.lifecycle_kind_label}
+ 진행 중 {metric.open_case_count}건 · 종료 확인 {metric.resolved_case_count}건 · 측정 근거 부족 {metric.evidence_missing_case_count}건
+
+ ))}
+
+
+ ) : null}
{!externalOnly ? (
) : null}
@@ -127,6 +148,26 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.case_kind_label} {item.project_name ?? "프로젝트 연결 분석 중"}
{item.summary_text}
{item.evidence_text}
+ {item.lifecycles.length ? (
+
+ {item.lifecycles.map((lifecycle) => (
+
+ {lifecycle.lifecycle_kind_label} {lifecycle.status_label}
+ {lifecycle.elapsed_seconds !== null ? 확정 경과 시간 {formatElapsed(lifecycle.elapsed_seconds)}
: 경과 시간은 종료 Event가 관측될 때 계산됩니다.
}
+
+ {[lifecycle.start_milestone, lifecycle.end_milestone].filter((milestone) => milestone !== null).map((milestone) => (
+
+ {milestone.observed_at}
+ {milestone.milestone_type_label} · {milestone.time_axis_label}
+ onOpenPost(milestone.evidence_post_id)}>{milestone.milestone_type_label} 근거 열기
+
+ ))}
+
+ 다음 조치: {lifecycle.next_action_text}
+
+ ))}
+
+ ) : null}
{item.facts.map((fact) =>
{fact.fact_type_label}{fact.relation_target_kind_label ? ` · ${fact.relation_target_kind_label}` : ""} {fact.value_text} onOpenPost(fact.evidence_post_id)}>{fact.fact_type_label} 근거 열기 )}
{item.missing_facts.length ? (
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py
index 3c054cc59..b27468706 100644
--- a/lineageweave/operations_case_analysis.py
+++ b/lineageweave/operations_case_analysis.py
@@ -5,6 +5,7 @@
import hashlib
import json
from dataclasses import dataclass
+from datetime import datetime
from typing import Protocol
from .http_client import chat_completion_content, post_json
@@ -14,20 +15,55 @@
)
FACT_TYPES = frozenset(
{
- "order", "specification_change", "originating_order", "sales_pool",
- "discussion", "counterparty", "our_owner", "decision", "external_relation",
- "issue_pattern", "improvement_action",
+ "order",
+ "specification_change",
+ "originating_order",
+ "sales_pool",
+ "discussion",
+ "counterparty",
+ "our_owner",
+ "decision",
+ "external_relation",
+ "issue_pattern",
+ "improvement_action",
}
)
EXTERNAL_RELATION_TARGET_KINDS = frozenset(
{"order", "project", "sales", "business_management"}
)
REQUIRED_FACT_TYPES = {
- "claim_investigation": frozenset({"order", "specification_change", "originating_order", "sales_pool"}),
- "rebid_handover": frozenset({"discussion", "counterparty", "our_owner", "decision"}),
+ "claim_investigation": frozenset(
+ {"order", "specification_change", "originating_order", "sales_pool"}
+ ),
+ "rebid_handover": frozenset(
+ {"discussion", "counterparty", "our_owner", "decision"}
+ ),
"external_information": frozenset({"external_relation"}),
"repeat_issue": frozenset({"issue_pattern", "improvement_action"}),
}
+MILESTONE_TYPES = frozenset(
+ {
+ "claim_received",
+ "cause_confirmed",
+ "rebid_response_requested",
+ "rebid_decision_recorded",
+ "handover_started",
+ "handover_accepted",
+ }
+)
+REQUIRED_MILESTONE_TYPES = {
+ "claim_investigation": frozenset({"claim_received", "cause_confirmed"}),
+ "rebid_handover": frozenset(
+ {
+ "rebid_response_requested",
+ "rebid_decision_recorded",
+ "handover_started",
+ "handover_accepted",
+ }
+ ),
+ "external_information": frozenset(),
+ "repeat_issue": frozenset(),
+}
@dataclass(frozen=True)
@@ -42,6 +78,18 @@ class OperationsCaseFact:
relation_target_kind_code: str | None = None
+@dataclass(frozen=True)
+class OperationsCaseMilestone:
+ """One semantically identified milestone bound to an observed source instant."""
+
+ milestone_type_code: str
+ evidence_text: str
+ evidence_post_id: str
+ evidence_input_sha256: str
+ observed_at: datetime
+ time_axis_code: str
+
+
@dataclass(frozen=True)
class OperationsCase:
"""One semantically classified operational case in a post."""
@@ -53,6 +101,8 @@ class OperationsCase:
evidence_post_id: str = ""
evidence_input_sha256: str = ""
missing_fact_type_codes: tuple[str, ...] = ()
+ milestones: tuple[OperationsCaseMilestone, ...] = ()
+ missing_milestone_type_codes: tuple[str, ...] = ()
@dataclass(frozen=True)
@@ -62,6 +112,8 @@ class OperationsEvidenceSource:
post_id: str
title: str
text: str
+ observed_at: datetime | None = None
+ time_axis_code: str | None = None
@property
def input_sha256(self) -> str:
@@ -109,7 +161,14 @@ def analyze(
specification_change, originating_order, sales_pool; rebid_handover = discussion,
counterparty, our_owner, decision; external_information = external_relation;
repeat_issue = issue_pattern, improvement_action. Return [] only when the record supports none
-of the case kinds.
+of the case kinds. Each item must also contain milestones and
+missing_milestone_type_codes. A milestone has milestone_type_code,
+evidence_post_id, and a verbatim evidence_text; its instant is assigned from
+that source record and must never be generated by the model. Required milestone
+types are: claim_investigation = claim_received, cause_confirmed;
+rebid_handover = rebid_response_requested, rebid_decision_recorded,
+handover_started, handover_accepted; the other case kinds have no milestones.
+Represent every required type exactly once as cited evidence or as missing.
Stored context (hints, not proof): {context}
Authorized numbered sources:
@@ -141,19 +200,39 @@ def parse_operations_case_response(
seen_case_kinds.add(item["case_kind_code"])
summary = item.get("summary_text")
evidence = item.get("evidence_text")
- evidence_post_id = item.get("evidence_post_id") or ("focal" if legacy_focal else None)
+ evidence_post_id = item.get("evidence_post_id") or (
+ "focal" if legacy_focal else None
+ )
facts = item.get("facts")
missing_fact_types = item.get("missing_fact_type_codes")
+ milestones = item.get("milestones")
+ missing_milestone_types = item.get("missing_milestone_type_codes")
evidence_source = sources_by_id.get(evidence_post_id)
- if not isinstance(summary, str) or not summary.strip() or not isinstance(evidence, str) or not evidence.strip() or evidence_source is None or evidence not in evidence_source.text or not isinstance(facts, list) or not isinstance(missing_fact_types, list):
+ if (
+ not isinstance(summary, str)
+ or not summary.strip()
+ or not isinstance(evidence, str)
+ or not evidence.strip()
+ or evidence_source is None
+ or evidence not in evidence_source.text
+ or not isinstance(facts, list)
+ or not isinstance(missing_fact_types, list)
+ or not isinstance(milestones, list)
+ or not isinstance(missing_milestone_types, list)
+ ):
return None
parsed_facts: list[OperationsCaseFact] = []
for fact in facts:
- if not isinstance(fact, dict) or fact.get("fact_type_code") not in FACT_TYPES:
+ if (
+ not isinstance(fact, dict)
+ or fact.get("fact_type_code") not in FACT_TYPES
+ ):
return None
value = fact.get("value_text")
fact_evidence = fact.get("evidence_text")
- fact_post_id = fact.get("evidence_post_id") or ("focal" if legacy_focal else None)
+ fact_post_id = fact.get("evidence_post_id") or (
+ "focal" if legacy_focal else None
+ )
fact_source = sources_by_id.get(fact_post_id)
relation_target_kind = fact.get("relation_target_kind_code")
if (
@@ -194,7 +273,82 @@ def parse_operations_case_response(
or not required_types.issubset(supported_types.union(missing_types))
):
return None
- cases.append(OperationsCase(item["case_kind_code"], summary.strip(), evidence, tuple(parsed_facts), evidence_source.post_id, evidence_source.input_sha256, tuple(missing_fact_types)))
+ parsed_milestones: list[OperationsCaseMilestone] = []
+ for milestone in milestones:
+ if (
+ not isinstance(milestone, dict)
+ or milestone.get("milestone_type_code") not in MILESTONE_TYPES
+ ):
+ return None
+ milestone_evidence = milestone.get("evidence_text")
+ milestone_post_id = milestone.get("evidence_post_id") or (
+ "focal" if legacy_focal else None
+ )
+ milestone_source = sources_by_id.get(milestone_post_id)
+ if (
+ not isinstance(milestone_evidence, str)
+ or not milestone_evidence.strip()
+ or milestone_source is None
+ or milestone_evidence not in milestone_source.text
+ or milestone_source.observed_at is None
+ or milestone_source.time_axis_code
+ not in {"event_occurred_at", "created_at"}
+ ):
+ return None
+ parsed_milestones.append(
+ OperationsCaseMilestone(
+ milestone["milestone_type_code"],
+ milestone_evidence,
+ milestone_source.post_id,
+ milestone_source.input_sha256,
+ milestone_source.observed_at,
+ milestone_source.time_axis_code,
+ )
+ )
+ supported_milestone_types = {
+ value.milestone_type_code for value in parsed_milestones
+ }
+ required_milestones = REQUIRED_MILESTONE_TYPES[item["case_kind_code"]]
+ if (
+ len(supported_milestone_types) != len(parsed_milestones)
+ or any(
+ not isinstance(code, str) or code not in MILESTONE_TYPES
+ for code in missing_milestone_types
+ )
+ or len(set(missing_milestone_types)) != len(missing_milestone_types)
+ or supported_milestone_types.intersection(missing_milestone_types)
+ or supported_milestone_types.union(missing_milestone_types)
+ != required_milestones
+ ):
+ return None
+ milestone_by_type = {
+ value.milestone_type_code: value for value in parsed_milestones
+ }
+ for start_code, end_code in (
+ ("claim_received", "cause_confirmed"),
+ ("rebid_response_requested", "rebid_decision_recorded"),
+ ("handover_started", "handover_accepted"),
+ ):
+ if (
+ start_code in milestone_by_type
+ and end_code in milestone_by_type
+ and milestone_by_type[end_code].observed_at
+ < milestone_by_type[start_code].observed_at
+ ):
+ return None
+ cases.append(
+ OperationsCase(
+ item["case_kind_code"],
+ summary.strip(),
+ evidence,
+ tuple(parsed_facts),
+ evidence_source.post_id,
+ evidence_source.input_sha256,
+ tuple(missing_fact_types),
+ tuple(parsed_milestones),
+ tuple(missing_milestone_types),
+ )
+ )
return tuple(cases)
@@ -214,11 +368,30 @@ def analyze(
"""Classify cases and reject any uncited or malformed result."""
response = post_json(
f"{self._base_url}/v1/chat/completions",
- {"messages": [{"role": "user", "content": _PROMPT.format(context=context, sources="\n\n".join(f"[Source {index}] post_id={source.post_id}\nTitle: {source.title}\n{source.text}" for index, source in enumerate(sources, 1)))}], "mode": "auto", "reasoning_effort": "auto"},
+ {
+ "messages": [
+ {
+ "role": "user",
+ "content": _PROMPT.format(
+ context=context,
+ sources="\n\n".join(
+ f"[Source {index}] post_id={source.post_id}\nTitle: {source.title}\n{source.text}"
+ for index, source in enumerate(sources, 1)
+ ),
+ ),
+ }
+ ],
+ "mode": "auto",
+ "reasoning_effort": "auto",
+ },
headers={"authorization": f"Bearer {self._api_key}"},
timeout=self._timeout,
)
- parsed = parse_operations_case_response(chat_completion_content(response), sources)
+ parsed = parse_operations_case_response(
+ chat_completion_content(response), sources
+ )
if parsed is None:
- raise ValueError("operations case response did not match the evidence contract")
+ raise ValueError(
+ "operations case response did not match the evidence contract"
+ )
return parsed
diff --git a/migrations/0215_operations_case_milestone.sql b/migrations/0215_operations_case_milestone.sql
new file mode 100644
index 000000000..614accb7c
--- /dev/null
+++ b/migrations/0215_operations_case_milestone.sql
@@ -0,0 +1,36 @@
+-- ADR 0206: observed lifecycle milestones; no inferred timestamps or delay threshold.
+create table if not exists operations_case_milestone (
+ post_id uuid not null,
+ case_kind_code text not null,
+ milestone_type_code text not null check (milestone_type_code in (
+ 'claim_received', 'cause_confirmed',
+ 'rebid_response_requested', 'rebid_decision_recorded',
+ 'handover_started', 'handover_accepted'
+ )),
+ evidence_text text not null check (btrim(evidence_text) <> ''),
+ evidence_post_id uuid not null references source_post(post_id) on delete restrict,
+ evidence_input_sha256 text not null check (evidence_input_sha256 ~ '^[0-9a-f]{64}$'),
+ observed_at timestamptz not null,
+ time_axis_code text not null check (time_axis_code in ('event_occurred_at', 'created_at')),
+ primary key (post_id, case_kind_code, milestone_type_code),
+ foreign key (post_id, case_kind_code)
+ references operations_case_classification(post_id, case_kind_code)
+ on delete cascade
+);
+
+create table if not exists operations_case_missing_milestone (
+ post_id uuid not null,
+ case_kind_code text not null,
+ milestone_type_code text not null check (milestone_type_code in (
+ 'claim_received', 'cause_confirmed',
+ 'rebid_response_requested', 'rebid_decision_recorded',
+ 'handover_started', 'handover_accepted'
+ )),
+ primary key (post_id, case_kind_code, milestone_type_code),
+ foreign key (post_id, case_kind_code)
+ references operations_case_classification(post_id, case_kind_code)
+ on delete cascade
+);
+
+create index if not exists operations_case_milestone_kind_time_idx
+ on operations_case_milestone (case_kind_code, milestone_type_code, observed_at, post_id);
diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py
index d5df88c2d..7b248c168 100644
--- a/tests/test_operations_case_analysis.py
+++ b/tests/test_operations_case_analysis.py
@@ -1,25 +1,75 @@
"""Operational case semantic-response contract tests."""
import json
+from datetime import UTC, datetime
-from lineageweave.operations_case_analysis import OperationsEvidenceSource, parse_operations_case_response
+from lineageweave.operations_case_analysis import (
+ OperationsEvidenceSource,
+ parse_operations_case_response,
+)
def test_parses_multiple_cases_and_grounded_facts() -> None:
"""One record may support multiple case kinds without losing evidence."""
body = "The revised specification caused the claim. Mina agreed with Alex to rebid."
payload = [
- {"case_kind_code": "claim_investigation", "summary_text": "Specification-linked claim", "evidence_text": "The revised specification caused the claim.", "facts": [{"fact_type_code": "specification_change", "value_text": "revised specification", "evidence_text": "The revised specification caused the claim."}], "missing_fact_type_codes": ["order", "originating_order", "sales_pool"]},
- {"case_kind_code": "rebid_handover", "summary_text": "Rebid agreement", "evidence_text": "Mina agreed with Alex to rebid.", "facts": [{"fact_type_code": "counterparty", "value_text": "Mina and Alex", "evidence_text": "Mina agreed with Alex to rebid."}], "missing_fact_type_codes": ["discussion", "our_owner", "decision"]},
+ {
+ "case_kind_code": "claim_investigation",
+ "summary_text": "Specification-linked claim",
+ "evidence_text": "The revised specification caused the claim.",
+ "facts": [
+ {
+ "fact_type_code": "specification_change",
+ "value_text": "revised specification",
+ "evidence_text": "The revised specification caused the claim.",
+ }
+ ],
+ "missing_fact_type_codes": ["order", "originating_order", "sales_pool"],
+ "milestones": [],
+ "missing_milestone_type_codes": ["claim_received", "cause_confirmed"],
+ },
+ {
+ "case_kind_code": "rebid_handover",
+ "summary_text": "Rebid agreement",
+ "evidence_text": "Mina agreed with Alex to rebid.",
+ "facts": [
+ {
+ "fact_type_code": "counterparty",
+ "value_text": "Mina and Alex",
+ "evidence_text": "Mina agreed with Alex to rebid.",
+ }
+ ],
+ "missing_fact_type_codes": ["discussion", "our_owner", "decision"],
+ "milestones": [],
+ "missing_milestone_type_codes": [
+ "rebid_response_requested",
+ "rebid_decision_recorded",
+ "handover_started",
+ "handover_accepted",
+ ],
+ },
]
result = parse_operations_case_response(json.dumps(payload), body)
assert result is not None
- assert [case.case_kind_code for case in result] == ["claim_investigation", "rebid_handover"]
+ assert [case.case_kind_code for case in result] == [
+ "claim_investigation",
+ "rebid_handover",
+ ]
def test_rejects_uncited_model_claim() -> None:
"""A plausible answer absent from the source is not persisted."""
- payload = [{"case_kind_code": "external_information", "summary_text": "Market note", "evidence_text": "invented", "facts": [], "missing_fact_type_codes": ["external_relation"]}]
+ payload = [
+ {
+ "case_kind_code": "external_information",
+ "summary_text": "Market note",
+ "evidence_text": "invented",
+ "facts": [],
+ "missing_fact_type_codes": ["external_relation"],
+ "milestones": [],
+ "missing_milestone_type_codes": [],
+ }
+ ]
assert parse_operations_case_response(json.dumps(payload), "source body") is None
@@ -31,17 +81,43 @@ def test_accepts_supported_no_case_result() -> None:
def test_rejects_unknown_codes_and_malformed_json() -> None:
"""Closed vocabularies prevent provider prose from entering persistence."""
assert parse_operations_case_response("not json", "body") is None
- assert parse_operations_case_response('[{"case_kind_code":"other"}]', "body") is None
+ assert (
+ parse_operations_case_response('[{"case_kind_code":"other"}]', "body") is None
+ )
def test_rejects_duplicate_case_kinds_and_blank_evidence() -> None:
"""One normalized key has one grounded classification, never an empty span."""
duplicate = [
- {"case_kind_code": "repeat_issue", "summary_text": "First", "evidence_text": "body", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]},
- {"case_kind_code": "repeat_issue", "summary_text": "Second", "evidence_text": "body", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]},
+ {
+ "case_kind_code": "repeat_issue",
+ "summary_text": "First",
+ "evidence_text": "body",
+ "facts": [],
+ "missing_fact_type_codes": ["issue_pattern", "improvement_action"],
+ "milestones": [],
+ "missing_milestone_type_codes": [],
+ },
+ {
+ "case_kind_code": "repeat_issue",
+ "summary_text": "Second",
+ "evidence_text": "body",
+ "facts": [],
+ "missing_fact_type_codes": ["issue_pattern", "improvement_action"],
+ "milestones": [],
+ "missing_milestone_type_codes": [],
+ },
]
blank = [
- {"case_kind_code": "repeat_issue", "summary_text": "Blank", "evidence_text": "", "facts": [], "missing_fact_type_codes": ["issue_pattern", "improvement_action"]}
+ {
+ "case_kind_code": "repeat_issue",
+ "summary_text": "Blank",
+ "evidence_text": "",
+ "facts": [],
+ "missing_fact_type_codes": ["issue_pattern", "improvement_action"],
+ "milestones": [],
+ "missing_milestone_type_codes": [],
+ }
]
assert parse_operations_case_response(json.dumps(duplicate), "body") is None
assert parse_operations_case_response(json.dumps(blank), "body") is None
@@ -51,21 +127,29 @@ def test_linked_fact_retains_its_authorized_source_post_and_input_digest() -> No
"""A linked specification fact is never attributed to the focal record."""
sources = (
OperationsEvidenceSource("focal", "Claim", "A claim was received."),
- OperationsEvidenceSource("linked", "Specification", "Specification S2 replaced S1."),
+ OperationsEvidenceSource(
+ "linked", "Specification", "Specification S2 replaced S1."
+ ),
)
- payload = [{
- "case_kind_code": "claim_investigation",
- "summary_text": "Specification changed before the claim",
- "evidence_post_id": "focal",
- "evidence_text": "A claim was received.",
- "facts": [{
- "fact_type_code": "specification_change",
- "value_text": "S2 replaced S1",
- "evidence_post_id": "linked",
- "evidence_text": "Specification S2 replaced S1.",
- }],
- "missing_fact_type_codes": ["order", "originating_order", "sales_pool"],
- }]
+ payload = [
+ {
+ "case_kind_code": "claim_investigation",
+ "summary_text": "Specification changed before the claim",
+ "evidence_post_id": "focal",
+ "evidence_text": "A claim was received.",
+ "facts": [
+ {
+ "fact_type_code": "specification_change",
+ "value_text": "S2 replaced S1",
+ "evidence_post_id": "linked",
+ "evidence_text": "Specification S2 replaced S1.",
+ }
+ ],
+ "missing_fact_type_codes": ["order", "originating_order", "sales_pool"],
+ "milestones": [],
+ "missing_milestone_type_codes": ["claim_received", "cause_confirmed"],
+ }
+ ]
result = parse_operations_case_response(json.dumps(payload), sources)
@@ -78,13 +162,17 @@ def test_linked_fact_retains_its_authorized_source_post_and_input_digest() -> No
def test_requires_each_case_question_to_be_supported_or_explicitly_missing() -> None:
"""The provider cannot silently omit or both support and miss a required answer."""
- payload = [{
- "case_kind_code": "external_information",
- "summary_text": "External notice",
- "evidence_text": "A public notice was published.",
- "facts": [],
- "missing_fact_type_codes": [],
- }]
+ payload = [
+ {
+ "case_kind_code": "external_information",
+ "summary_text": "External notice",
+ "evidence_text": "A public notice was published.",
+ "facts": [],
+ "missing_fact_type_codes": [],
+ "milestones": [],
+ "missing_milestone_type_codes": [],
+ }
+ ]
body = "A public notice was published."
assert parse_operations_case_response(json.dumps(payload), body) is None
@@ -102,6 +190,8 @@ def test_accepts_additional_grounded_fact_beyond_required_questions() -> None:
{"fact_type_code": "discussion", "value_text": "Claim discussion", "evidence_text": "claim changed"},
],
"missing_fact_type_codes": ["order", "originating_order"],
+ "milestones": [],
+ "missing_milestone_type_codes": ["claim_received", "cause_confirmed"],
}]
result = parse_operations_case_response(json.dumps(payload), body)
assert result is not None
@@ -139,6 +229,8 @@ def test_accepts_grounded_nonrequired_fact_after_required_questions_are_complete
},
],
"missing_fact_type_codes": [],
+ "milestones": [],
+ "missing_milestone_type_codes": [],
}]
assert parse_operations_case_response(json.dumps(payload), body) is not None
@@ -158,6 +250,8 @@ def test_external_relation_requires_a_semantic_target_type() -> None:
"evidence_text": body,
"facts": [fact],
"missing_fact_type_codes": [],
+ "milestones": [],
+ "missing_milestone_type_codes": [],
}]
result = parse_operations_case_response(json.dumps(payload), body)
diff --git a/tests/test_operations_case_ingestion.py b/tests/test_operations_case_ingestion.py
index e6df5b1b2..f24b1f3eb 100644
--- a/tests/test_operations_case_ingestion.py
+++ b/tests/test_operations_case_ingestion.py
@@ -1,9 +1,17 @@
"""Operational case persistence tests."""
import asyncio
+from datetime import UTC, datetime
-from backend.app.operations_case_ingestion import persist_operations_cases, source_body_digest
-from lineageweave.operations_case_analysis import OperationsCase, OperationsCaseFact
+from backend.app.operations_case_ingestion import (
+ persist_operations_cases,
+ source_body_digest,
+)
+from lineageweave.operations_case_analysis import (
+ OperationsCase,
+ OperationsCaseFact,
+ OperationsCaseMilestone,
+)
class _Transaction:
@@ -72,11 +80,59 @@ def test_persists_missing_required_facts_without_invented_evidence() -> None:
("order", "specification_change", "originating_order", "sales_pool"),
)
- asyncio.run(persist_operations_cases(conn, "post-1", "source", "session-1", (case,)))
+ asyncio.run(
+ persist_operations_cases(conn, "post-1", "source", "session-1", (case,))
+ )
+
+ assert conn.batches == [
+ [
+ ("post-1", "claim_investigation", "order"),
+ ("post-1", "claim_investigation", "specification_change"),
+ ("post-1", "claim_investigation", "originating_order"),
+ ("post-1", "claim_investigation", "sales_pool"),
+ ]
+ ]
+
+
+def test_persists_observed_and_missing_milestones_separately() -> None:
+ """An observed source instant is never replaced by an invented endpoint."""
+ conn = _Connection()
+ observed_at = datetime(2026, 8, 1, tzinfo=UTC)
+ case = OperationsCase(
+ "claim_investigation",
+ "Claim",
+ "source",
+ (),
+ "post-1",
+ "a" * 64,
+ ("order", "specification_change", "originating_order", "sales_pool"),
+ (
+ OperationsCaseMilestone(
+ "claim_received",
+ "source",
+ "post-1",
+ "a" * 64,
+ observed_at,
+ "event_occurred_at",
+ ),
+ ),
+ ("cause_confirmed",),
+ )
+
+ asyncio.run(
+ persist_operations_cases(conn, "post-1", "source", "session-1", (case,))
+ )
- assert conn.batches == [[
- ("post-1", "claim_investigation", "order"),
- ("post-1", "claim_investigation", "specification_change"),
- ("post-1", "claim_investigation", "originating_order"),
- ("post-1", "claim_investigation", "sales_pool"),
- ]]
+ assert conn.batches[-2] == [
+ (
+ "post-1",
+ "claim_investigation",
+ "claim_received",
+ "source",
+ "post-1",
+ "a" * 64,
+ observed_at,
+ "event_occurred_at",
+ )
+ ]
+ assert conn.batches[-1] == [("post-1", "claim_investigation", "cause_confirmed")]
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index 343df2b39..e5742d081 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -1,6 +1,6 @@
"""Focused tests for the operational dashboard evidence projection."""
-from datetime import date, datetime, timezone
+from datetime import UTC, date, datetime, timezone
import pytest
@@ -49,6 +49,29 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
"case_kind_code": "claim_investigation",
"fact_type_code": "sales_pool",
}]
+ if "operations_case_milestone milestone" in query:
+ return [
+ {
+ "post_id": "00000000-0000-0000-0000-000000000001",
+ "case_kind_code": "claim_investigation",
+ "milestone_type_code": "claim_received",
+ "evidence_text": "The claim was received",
+ "evidence_post_id": "00000000-0000-0000-0000-000000000001",
+ "observed_at": datetime(2026, 8, 1, 9, tzinfo=timezone.utc),
+ "time_axis_code": "event_occurred_at",
+ "is_missing": False,
+ },
+ {
+ "post_id": "00000000-0000-0000-0000-000000000001",
+ "case_kind_code": "claim_investigation",
+ "milestone_type_code": "cause_confirmed",
+ "evidence_text": "The cause was confirmed",
+ "evidence_post_id": "00000000-0000-0000-0000-000000000002",
+ "observed_at": datetime(2026, 8, 3, 12, 30, tzinfo=timezone.utc),
+ "time_axis_code": "created_at",
+ "is_missing": False,
+ },
+ ]
if "from topic_post_context_influence influence" in query:
return []
return [
@@ -140,11 +163,61 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None:
"missing_facts": [
{"fact_type_code": "sales_pool", "fact_type_label": "수주 Pool"}
],
+ "milestones": [
+ {
+ "milestone_type_code": "claim_received",
+ "milestone_type_label": "클레임 접수",
+ "evidence_text": "The claim was received",
+ "evidence_post_id": "00000000-0000-0000-0000-000000000001",
+ "observed_at": "2026-08-01T09:00:00+00:00",
+ "time_axis_code": "event_occurred_at",
+ "time_axis_label": "Event 발생일",
+ },
+ {
+ "milestone_type_code": "cause_confirmed",
+ "milestone_type_label": "원인 확정",
+ "evidence_text": "The cause was confirmed",
+ "evidence_post_id": "00000000-0000-0000-0000-000000000002",
+ "observed_at": "2026-08-03T12:30:00+00:00",
+ "time_axis_code": "created_at",
+ "time_axis_label": "기록 생성일",
+ },
+ ],
+ "lifecycles": [
+ {
+ "lifecycle_kind_code": "claim_investigation",
+ "lifecycle_kind_label": "클레임 원인 규명",
+ "status_code": "resolved",
+ "status_label": "종료 확인",
+ "started_at": "2026-08-01T09:00:00+00:00",
+ "resolved_at": "2026-08-03T12:30:00+00:00",
+ "elapsed_seconds": 185400,
+ "start_milestone": {
+ "milestone_type_code": "claim_received",
+ "milestone_type_label": "클레임 접수",
+ "evidence_text": "The claim was received",
+ "evidence_post_id": "00000000-0000-0000-0000-000000000001",
+ "observed_at": "2026-08-01T09:00:00+00:00",
+ "time_axis_code": "event_occurred_at",
+ "time_axis_label": "Event 발생일",
+ },
+ "end_milestone": {
+ "milestone_type_code": "cause_confirmed",
+ "milestone_type_label": "원인 확정",
+ "evidence_text": "The cause was confirmed",
+ "evidence_post_id": "00000000-0000-0000-0000-000000000002",
+ "observed_at": "2026-08-03T12:30:00+00:00",
+ "time_axis_code": "created_at",
+ "time_axis_label": "기록 생성일",
+ },
+ "next_action_text": "시작·종료 Event 근거를 열어 경과 시간을 검토하세요.",
+ }
+ ],
}
]
assert result["topic_context"]["status_code"] == "unavailable"
assert result["topic_context"]["reason_code"] == "tepp_topic_posterior_not_persisted"
- assert len(conn.queries) == 6
+ assert len(conn.queries) == 7
for query, args in conn.queries:
assert "visibility_code = 'public'" in query
assert "corporate_entity_id::text = any($1::text[])" in query
@@ -158,7 +231,18 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None:
)
case_query = conn.queries[1][0]
assert "order by primary_mention.confidence desc" in case_query
- assert "coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)" in case_query
+ assert (
+ "coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)"
+ in case_query
+ )
+ for evidence_query in (
+ conn.queries[0][0],
+ conn.queries[1][0],
+ conn.queries[2][0],
+ conn.queries[4][0],
+ ):
+ assert "join source_post evidence_post" in evidence_query
+ assert "evidence_post.corporate_entity_id::text = any($1::text[])" in evidence_query
@pytest.mark.anyio
@@ -278,7 +362,13 @@ async def fetchrow(self, query: str, *args: object) -> dict[str, int]:
"fast_mlsirm_influence_persisted": False,
}
return dict.fromkeys(
- ("total_post_count", "total_event_count", "external_post_count", "pending_analysis_count", "failed_analysis_count"),
+ (
+ "total_post_count",
+ "total_event_count",
+ "external_post_count",
+ "pending_analysis_count",
+ "failed_analysis_count",
+ ),
0,
)
@@ -313,6 +403,8 @@ async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
self.queries.append((query, args))
+ if "operations_case_milestone milestone" in query:
+ return []
if "from topic_post_context_influence influence" in query:
return []
if "operations_case_fact fact" in query:
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index 7bd661b44..46f9fa02d 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -4,6 +4,7 @@
import asyncio
from contextlib import asynccontextmanager
+from datetime import UTC, datetime
from types import SimpleNamespace
from backend.app import post_content_worker
@@ -109,6 +110,39 @@ async def gather(_conn, _post_id, can_see, _vision):
assert decisions == [True, False, False, True]
+def test_operations_sources_bind_milestones_to_source_owned_clocks(monkeypatch) -> None:
+ """The source row, not model output, supplies each milestone instant."""
+ observed_at = datetime(2026, 8, 1, 9, tzinfo=UTC)
+
+ async def gather(*_args):
+ return [SimpleNamespace(
+ post_id="00000000-0000-0000-0000-000000000001",
+ post_title="Synthetic claim",
+ post_body="A claim was received.",
+ evidence_facts=(),
+ )]
+
+ class SourceConnection(_Connection):
+ async def fetch(self, query: str, *_args: object):
+ assert "coalesce(event_occurred_at, created_at) as observed_at" in query
+ return [{
+ "post_id": "00000000-0000-0000-0000-000000000001",
+ "event_occurred_at": observed_at,
+ "observed_at": observed_at,
+ }]
+
+ monkeypatch.setattr(post_content_worker, "gather_chat_sources", gather)
+ sources = asyncio.run(post_content_worker._operations_evidence_sources(
+ _Pool(SourceConnection()),
+ "00000000-0000-0000-0000-000000000001",
+ {"corporate_entity_id": "corp", "process_unit_id": "pu"},
+ SimpleNamespace(available=False),
+ ))
+
+ assert sources[0].observed_at == observed_at
+ assert sources[0].time_axis_code == "event_occurred_at"
+
+
def test_terminal_failed_job_ignores_a_stale_duplicate_wakeup() -> None:
connection = _Connection(_row(FAILED, POST_CONTENT_MAX_ATTEMPTS))
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 5f7c730aa..0474ac74d 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -26,12 +26,16 @@
_ADMIN_DSN = os.environ.get(
"LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres"
)
-_MIGRATION_PATH = Path(__file__).resolve().parents[1] / "migrations" / "0001_initial_schema.sql"
+_MIGRATION_PATH = (
+ Path(__file__).resolve().parents[1] / "migrations" / "0001_initial_schema.sql"
+)
_MAJOR_EVENT_ACTION_MIGRATION = (
Path(__file__).resolve().parents[1] / "migrations" / "0100_major_event_action.sql"
)
_PROJECT_MENTION_MIGRATION = (
- Path(__file__).resolve().parents[1] / "migrations" / "0031_semantic_project_mentions.sql"
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0031_semantic_project_mentions.sql"
)
_PROJECT_BOUND_ACTION_MIGRATION = (
Path(__file__).resolve().parents[1]
@@ -74,7 +78,9 @@
/ "0169_report_leftover_map_axis.sql"
)
_CHANNEL_EVIDENCE_MIGRATION = (
- Path(__file__).resolve().parents[1] / "migrations" / "0174_post_lineage_edge_signal.sql"
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0174_post_lineage_edge_signal.sql"
)
_LEFTOVER_MAP_COVERAGE_MIGRATION = (
Path(__file__).resolve().parents[1]
@@ -87,13 +93,24 @@
/ "0182_report_leftover_map_unexplained.sql"
)
_OPERATIONS_CASE_MIGRATION = (
- Path(__file__).resolve().parents[1] / "migrations" / "0208_operations_case_analysis.sql"
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0208_operations_case_analysis.sql"
)
_OPERATIONS_CASE_EVIDENCE_MIGRATION = (
- Path(__file__).resolve().parents[1] / "migrations" / "0209_operations_case_evidence_source.sql"
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0209_operations_case_evidence_source.sql"
)
_OPERATIONS_CASE_MISSING_MIGRATION = (
- Path(__file__).resolve().parents[1] / "migrations" / "0211_operations_case_missing_fact.sql"
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0211_operations_case_missing_fact.sql"
+)
+_OPERATIONS_CASE_MILESTONE_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0215_operations_case_milestone.sql"
)
_ANALYSIS_RUN_REGISTRY_MIGRATION = (
Path(__file__).resolve().parents[1] / "migrations" / "0018_analysis_run_registry.sql"
From 72e0c9bb5fb46028a5bab486f82b48a67a271628 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 01:49:57 +0900
Subject: [PATCH 029/393] fix(dashboard): clarify observed lifecycle evidence
---
.../adr/0206-evidence-operations-dashboard.md | 30 +++++++++++++++++++
docs/product-technical-gap-baseline.md | 19 +++++++-----
.../components/OperationsDashboard.test.tsx | 15 ++++++++++
.../src/components/OperationsDashboard.tsx | 2 +-
4 files changed, 57 insertions(+), 9 deletions(-)
diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
index cacb982e2..371f41357 100644
--- a/docs/adr/0206-evidence-operations-dashboard.md
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -122,6 +122,36 @@ provenance.
to typed ontology properties. This is not a `knowledge_graph_edge` alias:
PostgreSQL operations tables remain authoritative, and an older untyped
relation remains absent from the typed projection until re-analysis.
+16. Claim investigation and rebid/handover use an observed event-log contract
+ aligned with IEEE 1849-2023 (XES). A classification is the local analysis
+ case identifier; a milestone has a closed activity code, an exact cited
+ evidence span, its evidence post, source digest, observed instant, and named
+ clock. Cross-post business-case identity is not inferred from project,
+ similarity, proximity, or text.
+17. Claim investigation pairs `claim_received` with `cause_confirmed`.
+ Rebid/handover independently pairs `rebid_response_requested` with
+ `rebid_decision_recorded`, and `handover_started` with
+ `handover_accepted`. Contextual-orchestrator identifies the supported
+ milestone semantics; LineageWeave assigns the instant only from that cited
+ `source_post`: `event_occurred_at` when present, otherwise the explicitly
+ labeled `created_at` fallback from ADR 0202. The model never emits a date.
+18. Each required endpoint is exactly one cited milestone or one normalized
+ missing-milestone row. Both observed endpoints produce the exact duration
+ `end - start`; start plus an explicitly missing end is `open`; a missing
+ start is `evidence_missing`. An open case has no elapsed duration because
+ no end instant was observed. Reversed observed endpoints reject the entire
+ provider result. No delay threshold, severity band, current-time endpoint,
+ imputed date, average, score, or arbitrary weight is introduced. Equal
+ source instants yield an auditable zero duration; they are not replaced by
+ an invented sub-record timestamp.
+19. The API rechecks the reader's current ABAC and source eligibility for each
+ classification, fact, and milestone evidence post before returning its
+ span. Consequently, aggregate counts exclude classifications whose cited
+ evidence is no longer authorized. The UI reports open, resolved, and
+ evidence-missing counts separately, shows exact elapsed seconds in a
+ lossless human-readable form, names each milestone's clock, and links the
+ reader to both endpoint sources. State and next action are conveyed in text
+ rather than color alone.
## Consequences
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 098b2b031..f948f44ca 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -80,15 +80,16 @@ remain local audit evidence and are not committed.
### Exact open-PR boundary
-At this snapshot there were 8 open PRs and 10 open issues. Exact observed heads
-were `#643 041ec13b`, `#640 985b4492`, `#639 aee02dca`, `#636 f7b9a65f`, `#632 e1ebe50a`,
-`#631 c0022c97`, `#629 74823e99`, and `#579 689a21b6`. All remain blocked on
+At this snapshot there were 10 open PRs and 10 open issues. Exact observed heads
+were `#654 ff096c18`, `#644 c1018a0a`, `#643 041ec13b`, `#640 6f421191`,
+`#639 aee02dca`, `#636 f7b9a65f`, `#632 d227edce`, `#631 c0022c97`,
+`#629 4b4d6707`, and `#579 689a21b6`. All remain blocked on
hosted gates and/or independent review. These
observations are not merge readiness. Re-fetch exact heads,
unresolved threads, checks, approvals, rulesets, and merge SHA before any
lifecycle claim.
-> Audit snapshot: 2026-08-26 (refreshed by the autonomous merge
+> Audit snapshot: 2026-08-26 01:50 KST (refreshed by the autonomous merge
> loop). This repository records synthetic fixtures and aggregate,
> non-identifying runtime evidence only. Open PRs and local checks are not
> protected-default-branch release evidence. Identifying post identifiers,
@@ -98,20 +99,22 @@ lifecycle claim.
## 1. Exact-head and governance evidence
The protected default branch was `04e6b610655d0db91d5f7ba9486bdda1440e0b19`
-when this baseline was refreshed. The live queue contained 8 open PRs and 10
+when this baseline was refreshed. The live queue contained 10 open PRs and 10
open issues. The exact-head inventory below supersedes older per-PR snapshots
elsewhere in this document; those older rows remain useful historical delivery
context only.
| PR | Exact observed head | Merge/check state at this snapshot |
| ---: | --- | --- |
+| #654 | `ff096c18` | repairs long ontology labels; hosted gates and independent review remain required |
+| #644 | `c1018a0a` | splits conditional frontend surfaces and preserves keyed recovery; hosted gates and independent review remain required |
| #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required |
-| #640 | `985b4492` | quantifies dashboard case metrics, preserves confidence-ranked project labels, persists explicit missing required facts, and enforces distinct event counts plus SQL-level external scoping; hosted gates and independent review remain required |
+| #640 | `6f421191` (observed parent) | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, and consumes producer-owned topic influence without local arithmetic; this documentation repair advances the head, so hosted gates and independent review must run again |
| #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required |
| #636 | `f7b9a65f` | publishes the calibrated external-lineage contract without a redundant explicit-child filter and repairs test import hygiene; hosted gates and independent review remain required |
-| #632 | `e1ebe50a` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
+| #632 | `d227edce` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
| #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required |
-| #629 | `74823e99` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required |
+| #629 | `4b4d6707` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required |
| #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required |
No row above is merge evidence. Immediately before any lifecycle action,
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index dbb9bb5d3..c61954710 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -66,6 +66,21 @@ describe("OperationsDashboardView", () => {
expect(onOpenPost).toHaveBeenCalledWith("evidence-post-1");
});
+ it("does not imply that only the end evidence is missing", () => {
+ const openLifecycle = {
+ ...data.cases[0].lifecycles[0],
+ status_code: "evidence_missing" as const,
+ status_label: "측정 근거 부족",
+ started_at: null,
+ resolved_at: null,
+ elapsed_seconds: null,
+ start_milestone: null,
+ end_milestone: null,
+ };
+ render( undefined} />);
+ expect(screen.getByText("경과 시간은 필요한 시작·종료 Event 근거가 모두 관측될 때 계산됩니다.")).toBeInTheDocument();
+ });
+
it("shows an actionable empty external-information state", () => {
render( undefined} />);
expect(screen.getByRole("status")).toHaveTextContent("기간이나 접근 범위를 확인하세요");
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index f7c93dacb..fc26540d4 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -153,7 +153,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.lifecycles.map((lifecycle) => (
{lifecycle.lifecycle_kind_label} {lifecycle.status_label}
- {lifecycle.elapsed_seconds !== null ? 확정 경과 시간 {formatElapsed(lifecycle.elapsed_seconds)}
: 경과 시간은 종료 Event가 관측될 때 계산됩니다.
}
+ {lifecycle.elapsed_seconds !== null ? 확정 경과 시간 {formatElapsed(lifecycle.elapsed_seconds)}
: 경과 시간은 필요한 시작·종료 Event 근거가 모두 관측될 때 계산됩니다.
}
{[lifecycle.start_milestone, lifecycle.end_milestone].filter((milestone) => milestone !== null).map((milestone) => (
From 6d397d3f8427bd29e47e82dd642e14b391d442dc Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 01:53:37 +0900
Subject: [PATCH 030/393] fix(dashboard): enforce provider and SQL boundaries
---
backend/app/operations_dashboard.py | 2 +-
backend/app/post_content_worker.py | 2 ++
frontend/src/api.ts | 2 +-
lineageweave/operations_case_analysis.py | 8 +++++--
tests/test_operations_case_analysis.py | 14 ++++++++++++
tests/test_operations_dashboard.py | 7 ++++--
tests/test_post_content_worker.py | 27 ++++++++++++++++++++++++
7 files changed, 56 insertions(+), 6 deletions(-)
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index 4238ecc8d..be6fbdbd5 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -314,7 +314,7 @@ async def fetch_operations_dashboard(
"topics": [],
}
if external_only
- else await _fetch_topic_context_dashboard(conn, visible, args)
+ else await _fetch_topic_context_dashboard(conn, visible, args[:4])
)
facts: dict[tuple[str, str], list[dict[str, str]]] = {}
for row in fact_rows:
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index fd7dd8d2f..10f8e958a 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -86,6 +86,8 @@ def can_see(row: asyncpg.Record) -> bool:
[source.post_id for source in sources],
)
}
+ if any(source.post_id not in source_times for source in sources):
+ raise RuntimeError("authorized evidence source clock unavailable")
return tuple(
OperationsEvidenceSource(
source.post_id,
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index fc76a6b6d..858b871b3 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -123,7 +123,7 @@ export interface OperationsDashboardResponse {
}
export interface TopicContextDashboard {
- status_code: "accepted" | "unavailable";
+ status_code: "accepted" | "unavailable" | "not_applicable";
reason_code: string | null;
next_action: string;
required_contracts: Array<{
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py
index b27468706..62471f7df 100644
--- a/lineageweave/operations_case_analysis.py
+++ b/lineageweave/operations_case_analysis.py
@@ -263,11 +263,15 @@ def parse_operations_case_response(
)
)
supported_types = {fact.fact_type_code for fact in parsed_facts}
+ if any(
+ not isinstance(code, str) or code not in FACT_TYPES
+ for code in missing_fact_types
+ ):
+ return None
missing_types = set(missing_fact_types)
required_types = REQUIRED_FACT_TYPES[item["case_kind_code"]]
if (
- any(not isinstance(code, str) or code not in FACT_TYPES for code in missing_fact_types)
- or len(missing_types) != len(missing_fact_types)
+ len(missing_types) != len(missing_fact_types)
or not missing_types.issubset(required_types)
or supported_types.intersection(missing_types)
or not required_types.issubset(supported_types.union(missing_types))
diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py
index 7b248c168..7d94f519a 100644
--- a/tests/test_operations_case_analysis.py
+++ b/tests/test_operations_case_analysis.py
@@ -73,6 +73,20 @@ def test_rejects_uncited_model_claim() -> None:
assert parse_operations_case_response(json.dumps(payload), "source body") is None
+def test_rejects_unhashable_missing_fact_code() -> None:
+ """Malformed provider arrays are rejected without escaping the parser."""
+ payload = [{
+ "case_kind_code": "external_information",
+ "summary_text": "Market note",
+ "evidence_text": "source body",
+ "facts": [],
+ "missing_fact_type_codes": [{}],
+ "milestones": [],
+ "missing_milestone_type_codes": [],
+ }]
+ assert parse_operations_case_response(json.dumps(payload), "source body") is None
+
+
def test_accepts_supported_no_case_result() -> None:
"""An empty semantic result remains distinct from malformed output."""
assert parse_operations_case_response("[]", "ordinary status") == ()
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index e5742d081..eac3776c9 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -16,6 +16,7 @@ def __init__(self) -> None:
async def fetchrow(self, query: str, *args: object) -> dict[str, int]:
self.queries.append((query, args))
if "tepp_posterior_persisted" in query:
+ assert len(args) == 4
return {
"tepp_posterior_persisted": False,
"fast_mlsirm_influence_persisted": False,
@@ -73,6 +74,7 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
},
]
if "from topic_post_context_influence influence" in query:
+ assert len(args) == 4
return []
return [
{
@@ -223,12 +225,13 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None:
assert "corporate_entity_id::text = any($1::text[])" in query
assert "process_unit_id::text = any($2::text[])" in query
assert "coalesce(post.event_occurred_at, post.created_at)" in query
- assert args[1:] == (
+ assert args[:4] == (
+ ["00000000-0000-0000-0000-000000000009"],
["00000000-0000-0000-0000-000000000008"],
date(2026, 8, 1),
date(2026, 8, 31),
- False,
)
+ assert args[4:] == ((False,) if "$5" in query else ())
case_query = conn.queries[1][0]
assert "order by primary_mention.confidence desc" in case_query
assert (
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index 46f9fa02d..b14fe75df 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -7,6 +7,8 @@
from datetime import UTC, datetime
from types import SimpleNamespace
+import pytest
+
from backend.app import post_content_worker
from backend.app.post_content_queue import (
FAILED,
@@ -143,6 +145,31 @@ async def fetch(self, query: str, *_args: object):
assert sources[0].time_axis_code == "event_occurred_at"
+def test_operations_sources_retry_when_a_source_clock_disappears(monkeypatch) -> None:
+ """A source deleted during assembly fails explicitly instead of inventing time."""
+
+ async def gather(*_args):
+ return [SimpleNamespace(
+ post_id="00000000-0000-0000-0000-000000000001",
+ post_title="Synthetic claim",
+ post_body="A claim was received.",
+ evidence_facts=(),
+ )]
+
+ class MissingClockConnection(_Connection):
+ async def fetch(self, *_args: object):
+ return []
+
+ monkeypatch.setattr(post_content_worker, "gather_chat_sources", gather)
+ with pytest.raises(RuntimeError, match="source clock unavailable"):
+ asyncio.run(post_content_worker._operations_evidence_sources(
+ _Pool(MissingClockConnection()),
+ "00000000-0000-0000-0000-000000000001",
+ {"corporate_entity_id": "corp", "process_unit_id": "pu"},
+ SimpleNamespace(available=False),
+ ))
+
+
def test_terminal_failed_job_ignores_a_stale_duplicate_wakeup() -> None:
connection = _Connection(_row(FAILED, POST_CONTENT_MAX_ATTEMPTS))
From b045a6e5aa06e115011d5357a32a717f1628143a Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 01:58:38 +0900
Subject: [PATCH 031/393] fix(dashboard): bind evidence and topic query scopes
---
backend/app/operations_dashboard.py | 1 -
tests/test_operations_dashboard.py | 1 -
2 files changed, 2 deletions(-)
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index be6fbdbd5..6da64b5ba 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -275,7 +275,6 @@ async def fetch_operations_dashboard(
from operations_case_missing_fact missing
join source_post post on post.post_id = missing.post_id
where {visible}
- and {visible_evidence}
and ($5::boolean is false or missing.case_kind_code = 'external_information')
order by missing.post_id, missing.case_kind_code, missing.fact_type_code
""",
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index eac3776c9..37aed4dae 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -242,7 +242,6 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None:
conn.queries[0][0],
conn.queries[1][0],
conn.queries[2][0],
- conn.queries[4][0],
):
assert "join source_post evidence_post" in evidence_query
assert "evidence_post.corporate_entity_id::text = any($1::text[])" in evidence_query
From 49ab9bb1ba3e239ce02918d2e08726a1708d10be Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 02:00:03 +0900
Subject: [PATCH 032/393] docs(operability): record dashboard concurrency
evidence
---
docs/operability/http-concurrency-evidence.md | 20 +++++++++++++++++++
docs/product-technical-gap-baseline.md | 7 ++++---
2 files changed, 24 insertions(+), 3 deletions(-)
diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md
index 1db0922a6..0ec1d2c2d 100644
--- a/docs/operability/http-concurrency-evidence.md
+++ b/docs/operability/http-concurrency-evidence.md
@@ -56,6 +56,26 @@ or shared-runner result to a product guarantee.
Figma and screenshot review do not apply: this is a non-UI HTTP load harness.
+## Dashboard candidate verification record
+
+On 2026-08-26, the synthetic 27-post Compose dataset at candidate head
+`b045a6e5` ran with 4 VUs for 30 seconds on alternate local ports. It completed
+1,240 iterations and 4,962 authenticated HTTP requests with zero failed
+requests and 4,960/4,960 successful checks across posts, Event Lineage,
+Dashboard, and Ask polling. Overall request duration was 75.02 ms average,
+56.73 ms median, 181.76 ms p95, and 791.89 ms maximum; the combined reader
+metric was 81.68 ms average and 197.25 ms p95. The one Ask enqueue took
+173.66 ms, while Ask polling averaged 54.80 ms with 132.98 ms p95.
+
+The first candidate run exposed two Dashboard-only SQL contract defects:
+an evidence-post predicate in the missing-fact query despite that query having
+no evidence-post join, and a fifth bind value passed to the four-parameter
+topic projection. Both failed every Dashboard request while sibling endpoints
+remained responsive. The shared query boundary was repaired and regression
+tests now assert the join and bind arity; the distribution above is the clean
+rerun. This is synthetic candidate evidence, not protected-main evidence or a
+capacity/SLO claim.
+
## Current-main verification record
On 2026-08-25, a worktree based on protected-main commit `48f013a2` passed
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index f948f44ca..ca95fd0f2 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -1,7 +1,8 @@
# Product & Technical Gap Baseline
> Dashboard delivery snapshot: 2026-08-26 (latest exact-head fetch). Protected `main` was
-> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. This local branch is not
+> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. Dashboard candidate head
+> `b045a6e5` has local synthetic runtime evidence; this is not
> protected-main release evidence.
## Operations Dashboard PRD/TRD traceability
@@ -109,7 +110,7 @@ context only.
| #654 | `ff096c18` | repairs long ontology labels; hosted gates and independent review remain required |
| #644 | `c1018a0a` | splits conditional frontend surfaces and preserves keyed recovery; hosted gates and independent review remain required |
| #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required |
-| #640 | `6f421191` (observed parent) | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, and consumes producer-owned topic influence without local arithmetic; this documentation repair advances the head, so hosted gates and independent review must run again |
+| #640 | `b045a6e5` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, and repairs runtime evidence-query joins/bind arity; hosted gates and independent review must run on this exact head |
| #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required |
| #636 | `f7b9a65f` | publishes the calibrated external-lineage contract without a redundant explicit-child filter and repairs test import hygiene; hosted gates and independent review remain required |
| #632 | `d227edce` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
@@ -400,7 +401,7 @@ this file per §3.5 of the prior snapshot).
| Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push |
| Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc |
| Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence |
-| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work, and the synthetic Compose boundary has an authenticated k6 E2E harness for Ask enqueue, concurrent reads, and job polling. An older-image local observation found repeated post-filter queries while `/api/posts` exceeded 30 seconds; ADR 0212 combines two authorized filter-option queries into one round trip without narrowing ABAC-visible options. The observation is not exact-head evidence or a product guarantee, and no physical scan reduction is claimed without an exact-head plan | Rebuild an exact-head application image, run `make load-http` with declared environment concurrency/window, and retain raw distributions and resource configuration. Compare the post-list database plan and latency with ADR 0212 while preserving the complete authorized filter set; set no SLO until representative capacity evidence is approved |
+| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `b045a6e5` completed a synthetic authenticated 4-VU/30-second k6 run: 4,962 requests, zero failures, HTTP p95 181.76 ms, and reader p95 197.25 ms. The first run exposed and the candidate repaired Dashboard evidence-join and bind-arity defects. This is local candidate evidence, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence |
| Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing |
| Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts |
| Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence |
From c780579248a67b98da545e55a2e4845d1d7404b0 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 02:01:10 +0900
Subject: [PATCH 033/393] test(dashboard): execute SQL against PostgreSQL
---
.../test_operations_dashboard_postgres.py | 38 +++++++++++++++++++
1 file changed, 38 insertions(+)
create mode 100644 backend/tests/test_operations_dashboard_postgres.py
diff --git a/backend/tests/test_operations_dashboard_postgres.py b/backend/tests/test_operations_dashboard_postgres.py
new file mode 100644
index 000000000..d03bc94b7
--- /dev/null
+++ b/backend/tests/test_operations_dashboard_postgres.py
@@ -0,0 +1,38 @@
+"""Real-PostgreSQL contract test for the operations Dashboard projection."""
+
+from __future__ import annotations
+
+import os
+
+import asyncpg
+import pytest
+
+from backend.app.operations_dashboard import fetch_operations_dashboard
+
+
+_POSTGRES_DSN = os.environ.get(
+ "LINEAGEWEAVE_TEST_POSTGRES_DSN",
+ "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave",
+)
+
+
+@pytest.mark.anyio
+async def test_operations_dashboard_sql_binds_against_postgres() -> None:
+ """Execute every Dashboard query through asyncpg's real parser and binder."""
+ try:
+ connection = await asyncpg.connect(_POSTGRES_DSN, timeout=2)
+ except (OSError, asyncpg.PostgresError):
+ pytest.skip("requires the migrated local Compose database")
+ try:
+ result = await fetch_operations_dashboard(connection, [])
+ finally:
+ await connection.close()
+
+ assert result["total_post_count"] >= 0
+ assert result["topic_context"]["status_code"] in {"accepted", "unavailable"}
+
+
+@pytest.fixture
+def anyio_backend() -> str:
+ """Use the installed asyncio backend for the asyncpg contract test."""
+ return "asyncio"
From 2b6571b40ca98f4e7ee4fccc7694e9c4ea39b8e2 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 02:02:04 +0900
Subject: [PATCH 034/393] test(api): isolate seeded analysis snapshots
---
backend/tests/test_api.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index df1744dd2..78b99ab08 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -568,7 +568,7 @@ def _seed_analysis_run(
)
other_account_id = cur.fetchone()[0]
visible_run_id = _seed_analysis_run(
- "a" * 64,
+ "f" * 64,
"visible-own-corp",
account_id,
"analysis_scope_corporate_entity",
From 06ddcc106b86bd211e0c185f8ad7f0179eb35112 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 02:05:46 +0900
Subject: [PATCH 035/393] docs(gaps): refresh exact dashboard queue
---
docs/product-technical-gap-baseline.md | 13 ++++++-------
1 file changed, 6 insertions(+), 7 deletions(-)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index ca95fd0f2..2bb4bfa47 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -81,9 +81,9 @@ remain local audit evidence and are not committed.
### Exact open-PR boundary
-At this snapshot there were 10 open PRs and 10 open issues. Exact observed heads
-were `#654 ff096c18`, `#644 c1018a0a`, `#643 041ec13b`, `#640 6f421191`,
-`#639 aee02dca`, `#636 f7b9a65f`, `#632 d227edce`, `#631 c0022c97`,
+At this snapshot there were 9 open PRs and 10 open issues. Exact observed heads
+were `#644 c1018a0a`, `#643 041ec13b`, `#640 8750638c`,
+`#639 aee02dca`, `#636 f7b9a65f`, `#632 a946f879`, `#631 c0022c97`,
`#629 4b4d6707`, and `#579 689a21b6`. All remain blocked on
hosted gates and/or independent review. These
observations are not merge readiness. Re-fetch exact heads,
@@ -100,20 +100,19 @@ lifecycle claim.
## 1. Exact-head and governance evidence
The protected default branch was `04e6b610655d0db91d5f7ba9486bdda1440e0b19`
-when this baseline was refreshed. The live queue contained 10 open PRs and 10
+when this baseline was refreshed. The live queue contained 9 open PRs and 10
open issues. The exact-head inventory below supersedes older per-PR snapshots
elsewhere in this document; those older rows remain useful historical delivery
context only.
| PR | Exact observed head | Merge/check state at this snapshot |
| ---: | --- | --- |
-| #654 | `ff096c18` | repairs long ontology labels; hosted gates and independent review remain required |
| #644 | `c1018a0a` | splits conditional frontend surfaces and preserves keyed recovery; hosted gates and independent review remain required |
| #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required |
-| #640 | `b045a6e5` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, and repairs runtime evidence-query joins/bind arity; hosted gates and independent review must run on this exact head |
+| #640 | `8750638c` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, and repairs runtime evidence-query joins/bind arity; hosted gates and independent review must run on this exact head |
| #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required |
| #636 | `f7b9a65f` | publishes the calibrated external-lineage contract without a redundant explicit-child filter and repairs test import hygiene; hosted gates and independent review remain required |
-| #632 | `d227edce` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
+| #632 | `a946f879` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
| #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required |
| #629 | `4b4d6707` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required |
| #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required |
From a60e5060d16bfea01e4791036779964be1937edb Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 02:15:17 +0900
Subject: [PATCH 036/393] test(api): isolate live integration evidence
---
backend/tests/test_api.py | 10 ++++++++--
backend/tests/test_operations_dashboard_postgres.py | 4 ++++
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 78b99ab08..5538f465b 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -568,7 +568,7 @@ def _seed_analysis_run(
)
other_account_id = cur.fetchone()[0]
visible_run_id = _seed_analysis_run(
- "f" * 64,
+ "0" * 64,
"visible-own-corp",
account_id,
"analysis_scope_corporate_entity",
@@ -3900,7 +3900,9 @@ def answer(self, question: str, sources) -> None:
)
assert response.status_code == 503
- assert "no complete evidence object" in response.json()["detail"]
+ assert response.json()["detail"] == (
+ "Post chat is temporarily unavailable. Saved evidence is still available."
+ )
def test_live_chat_provider_error_does_not_leak_raw_error(
@@ -3943,7 +3945,11 @@ class _FailingAskClient:
def answer(self, question: str, sources) -> object:
raise Exception("raw-global-provider-secret")
+ async def _sources(*_args, **_kwargs):
+ return [SimpleNamespace(post_id=seeded_db["own_private_post_id"])]
+
monkeypatch.setattr("backend.app.main._post_chat_client", lambda **_kwargs: _FailingAskClient())
+ monkeypatch.setattr("backend.app.global_ask_queue.gather_global_chat_sources", _sources)
headers = {"Authorization": f"Bearer {demo_analyst_token}"}
submitted = client.post(
diff --git a/backend/tests/test_operations_dashboard_postgres.py b/backend/tests/test_operations_dashboard_postgres.py
index d03bc94b7..16135c311 100644
--- a/backend/tests/test_operations_dashboard_postgres.py
+++ b/backend/tests/test_operations_dashboard_postgres.py
@@ -24,6 +24,10 @@ async def test_operations_dashboard_sql_binds_against_postgres() -> None:
except (OSError, asyncpg.PostgresError):
pytest.skip("requires the migrated local Compose database")
try:
+ if await connection.fetchval(
+ "select to_regclass('public.operations_case_classification')"
+ ) is None:
+ pytest.skip("requires migration 0209 on the local Compose database")
result = await fetch_operations_dashboard(connection, [])
finally:
await connection.close()
From 6b1951716a26d5d4a006387b1a2a7ea5146371ed Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 02:21:16 +0900
Subject: [PATCH 037/393] fix(ui): keep workspace navigation reachable on
mobile
---
frontend/src/App.css | 15 ++++++++++++---
frontend/src/mobileNavigationCss.test.ts | 13 +++++++++++++
2 files changed, 25 insertions(+), 3 deletions(-)
create mode 100644 frontend/src/mobileNavigationCss.test.ts
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 123d01254..a4fb2d71e 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1097,13 +1097,22 @@
@media (max-width: 768px) {
/* Phone Breakpoint (<768px) */
-
+
.workspace-gnb {
- display: none; /* Replaced by drawer on mobile */
+ overflow-x: auto;
+ overscroll-behavior-inline: contain;
+ gap: 0.75rem;
+ padding: 0 1rem;
+ scrollbar-width: thin;
+ }
+
+ .workspace-gnb-item,
+ .workspace-gnb-tools {
+ flex: 0 0 auto;
}
.mobile-drawer-trigger {
- display: block;
+ display: none;
}
.app-header {
diff --git a/frontend/src/mobileNavigationCss.test.ts b/frontend/src/mobileNavigationCss.test.ts
new file mode 100644
index 000000000..bd2a37f52
--- /dev/null
+++ b/frontend/src/mobileNavigationCss.test.ts
@@ -0,0 +1,13 @@
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { expect, it } from "vitest";
+
+const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "App.css"), "utf-8");
+
+it("keeps the workspace GNB reachable on mobile", () => {
+ const mobile = css.match(/@media \(max-width: 768px\) \{([\s\S]*?)\n\}/)?.[1] ?? "";
+ expect(mobile).toContain(".workspace-gnb");
+ expect(mobile).toContain("overflow-x: auto");
+ expect(mobile).not.toContain(".workspace-gnb {\n display: none");
+});
From 89c44db8643b75bd5e089b111c12a876804a2413 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 02:22:21 +0900
Subject: [PATCH 038/393] docs(gaps): record authenticated mobile audit
---
docs/product-technical-gap-baseline.md | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 2bb4bfa47..7adaa1e79 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -78,11 +78,19 @@ stories were re-rendered locally with synthetic data at desktop and iPhone
actions; the narrow view preserves readable cards and 44px-class actions while
keeping the multi-step project journey horizontally scrollable. These images
remain local audit evidence and are not committed.
+An authenticated synthetic OIDC audit then found that the mobile breakpoint
+hid the entire GNB despite having no drawer implementation. Candidate
+`6b195171` keeps the same semantic navigation in a keyboard-accessible
+horizontal viewport. The 390×844 rerender showed the GNB, one `main`, zero
+unnamed controls, no document-level horizontal overflow, and visible keyboard
+focus. The isolated frontend/backend issuer mismatch returned Dashboard 401,
+so populated authenticated data remains a separate acceptance item; the k6
+API run independently proved the Dashboard endpoint with the isolated issuer.
### Exact open-PR boundary
At this snapshot there were 9 open PRs and 10 open issues. Exact observed heads
-were `#644 c1018a0a`, `#643 041ec13b`, `#640 8750638c`,
+were `#644 c1018a0a`, `#643 041ec13b`, `#640 6b195171`,
`#639 aee02dca`, `#636 f7b9a65f`, `#632 a946f879`, `#631 c0022c97`,
`#629 4b4d6707`, and `#579 689a21b6`. All remain blocked on
hosted gates and/or independent review. These
@@ -109,7 +117,7 @@ context only.
| ---: | --- | --- |
| #644 | `c1018a0a` | splits conditional frontend surfaces and preserves keyed recovery; hosted gates and independent review remain required |
| #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required |
-| #640 | `8750638c` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, and repairs runtime evidence-query joins/bind arity; hosted gates and independent review must run on this exact head |
+| #640 | `6b195171` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, repairs runtime evidence-query joins/bind arity, and restores mobile GNB reachability; hosted gates and independent review must run on this exact head |
| #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required |
| #636 | `f7b9a65f` | publishes the calibrated external-lineage contract without a redundant explicit-child filter and repairs test import hygiene; hosted gates and independent review remain required |
| #632 | `a946f879` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
From 90995cea12442539d83f2569c537b65ce8cd45cb Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 02:40:49 +0900
Subject: [PATCH 039/393] fix(dashboard): enforce temporal evidence contracts
---
backend/app/operations_dashboard.py | 3 +-
.../test_operations_dashboard_postgres.py | 17 +-
.../adr/0206-evidence-operations-dashboard.md | 5 +-
...poral-topic-context-influence-dashboard.md | 10 +-
docs/operability/http-concurrency-evidence.md | 2 +-
docs/product-technical-gap-baseline.md | 2 +-
lineageweave/operations_case_analysis.py | 13 +-
...13_operations_external_relation_target.sql | 2 +-
...214_topic_context_influence_projection.sql | 118 +++++++++++
migrations/0215_operations_case_milestone.sql | 24 +++
...6_validate_operations_case_constraints.sql | 9 +
tests/test_operations_case_analysis.py | 22 ++
tests/test_operations_dashboard.py | 1 +
tests/test_schema.py | 200 +++++++++++++++++-
14 files changed, 414 insertions(+), 14 deletions(-)
create mode 100644 migrations/0216_validate_operations_case_constraints.sql
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index 6da64b5ba..a7d826ebd 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -299,7 +299,8 @@ async def fetch_operations_dashboard(
join source_post post on post.post_id = missing.post_id
where {visible}
and ($5::boolean is false or missing.case_kind_code = 'external_information')
- order by post_id, case_kind_code, milestone_type_code
+ order by post_id, case_kind_code, observed_at nulls last,
+ milestone_type_code
""",
*args,
)
diff --git a/backend/tests/test_operations_dashboard_postgres.py b/backend/tests/test_operations_dashboard_postgres.py
index 16135c311..ef529867f 100644
--- a/backend/tests/test_operations_dashboard_postgres.py
+++ b/backend/tests/test_operations_dashboard_postgres.py
@@ -24,10 +24,19 @@ async def test_operations_dashboard_sql_binds_against_postgres() -> None:
except (OSError, asyncpg.PostgresError):
pytest.skip("requires the migrated local Compose database")
try:
- if await connection.fetchval(
- "select to_regclass('public.operations_case_classification')"
- ) is None:
- pytest.skip("requires migration 0209 on the local Compose database")
+ required_tables = (
+ "operations_case_classification",
+ "operations_case_missing_fact",
+ "operations_case_milestone",
+ "operations_case_missing_milestone",
+ "topic_context_membership",
+ "topic_post_context_influence",
+ )
+ for table_name in required_tables:
+ if await connection.fetchval(
+ "select to_regclass($1)", f"public.{table_name}"
+ ) is None:
+ pytest.skip(f"requires the migration that creates {table_name}")
result = await fetch_operations_dashboard(connection, [])
finally:
await connection.close()
diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
index 371f41357..a1235c232 100644
--- a/docs/adr/0206-evidence-operations-dashboard.md
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -131,7 +131,10 @@ provenance.
17. Claim investigation pairs `claim_received` with `cause_confirmed`.
Rebid/handover independently pairs `rebid_response_requested` with
`rebid_decision_recorded`, and `handover_started` with
- `handover_accepted`. Contextual-orchestrator identifies the supported
+ `handover_accepted`. The database rejects a claim milestone on a
+ rebid/handover case, a rebid/handover milestone on a claim case, and every
+ milestone on the other case kinds; the same invariant applies to observed
+ and explicitly missing endpoints. Contextual-orchestrator identifies the supported
milestone semantics; LineageWeave assigns the instant only from that cited
`source_post`: `event_occurred_at` when present, otherwise the explicitly
labeled `created_at` fallback from ADR 0202. The model never emits a date.
diff --git a/docs/adr/0210-temporal-topic-context-influence-dashboard.md b/docs/adr/0210-temporal-topic-context-influence-dashboard.md
index 0cfdbe1d8..ad266a6da 100644
--- a/docs/adr/0210-temporal-topic-context-influence-dashboard.md
+++ b/docs/adr/0210-temporal-topic-context-influence-dashboard.md
@@ -87,7 +87,15 @@ The accepted TEPP result schema must include:
LineageWeave verifies the exact snapshot and cutoff before persisting a 3NF
projection. It does not inspect TEPP's private tables or reinterpret posterior
-coordinates.
+coordinates. `topic_model_run.coordinate_kind_code` fixes one representation
+for the run; `topic_post_coordinate` stores one finite value per run, post,
+topic, and posterior-draw ordinal, and the ordinal must belong to the run's
+declared draw set. Topic-lineage and context-membership evidence
+each references a normalized `provenance_assertion` whose canonical relation is
+`prov:wasDerivedFrom`; its SHA-256 remains an integrity field rather than a
+substitute for provenance. Import materializes that assertion through
+`lineageweave.prov_o.ProvGraph` so PROV-O hierarchy and qualified-relation
+implications remain the shared standard projection.
TEPP protected main currently exposes `tepp.trsl_topic_lineage.v1`, a
digest-bound CPU-`f64` artifact containing fitted forward sequence edges and
diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md
index 0ec1d2c2d..eca0b8a6a 100644
--- a/docs/operability/http-concurrency-evidence.md
+++ b/docs/operability/http-concurrency-evidence.md
@@ -58,7 +58,7 @@ Figma and screenshot review do not apply: this is a non-UI HTTP load harness.
## Dashboard candidate verification record
-On 2026-08-26, the synthetic 27-post Compose dataset at candidate head
+On 2026-08-26 KST (2026-08-25 UTC), the synthetic 27-post Compose dataset at candidate head
`b045a6e5` ran with 4 VUs for 30 seconds on alternate local ports. It completed
1,240 iterations and 4,962 authenticated HTTP requests with zero failed
requests and 4,960/4,960 successful checks across posts, Event Lineage,
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 7adaa1e79..0761f7175 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -1,6 +1,6 @@
# Product & Technical Gap Baseline
-> Dashboard delivery snapshot: 2026-08-26 (latest exact-head fetch). Protected `main` was
+> Dashboard delivery snapshot: 2026-08-26 KST (2026-08-25 UTC; latest exact-head fetch). Protected `main` was
> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. Dashboard candidate head
> `b045a6e5` has local synthetic runtime evidence; this is not
> protected-main release evidence.
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py
index 62471f7df..74ca912d3 100644
--- a/lineageweave/operations_case_analysis.py
+++ b/lineageweave/operations_case_analysis.py
@@ -262,7 +262,15 @@ def parse_operations_case_response(
relation_target_kind,
)
)
- supported_types = {fact.fact_type_code for fact in parsed_facts}
+ supported_type_counts = {
+ fact_type: sum(
+ fact.fact_type_code == fact_type for fact in parsed_facts
+ )
+ for fact_type in FACT_TYPES
+ }
+ supported_types = {
+ fact_type for fact_type, count in supported_type_counts.items() if count
+ }
if any(
not isinstance(code, str) or code not in FACT_TYPES
for code in missing_fact_types
@@ -271,7 +279,8 @@ def parse_operations_case_response(
missing_types = set(missing_fact_types)
required_types = REQUIRED_FACT_TYPES[item["case_kind_code"]]
if (
- len(missing_types) != len(missing_fact_types)
+ any(supported_type_counts[fact_type] > 1 for fact_type in required_types)
+ or len(missing_types) != len(missing_fact_types)
or not missing_types.issubset(required_types)
or supported_types.intersection(missing_types)
or not required_types.issubset(supported_types.union(missing_types))
diff --git a/migrations/0213_operations_external_relation_target.sql b/migrations/0213_operations_external_relation_target.sql
index eb059744d..5fe13a48f 100644
--- a/migrations/0213_operations_external_relation_target.sql
+++ b/migrations/0213_operations_external_relation_target.sql
@@ -9,7 +9,7 @@ alter table operations_case_fact
and (relation_target_kind_code is null or relation_target_kind_code in
('order', 'project', 'sales', 'business_management')))
or (fact_type_code <> 'external_relation' and relation_target_kind_code is null)
- );
+ ) not valid;
comment on column operations_case_fact.relation_target_kind_code is
'Semantic target type supplied with cited external_relation evidence; null legacy rows are not projected as typed relations.';
diff --git a/migrations/0214_topic_context_influence_projection.sql b/migrations/0214_topic_context_influence_projection.sql
index 13daab657..ffa88b202 100644
--- a/migrations/0214_topic_context_influence_projection.sql
+++ b/migrations/0214_topic_context_influence_projection.sql
@@ -14,6 +14,9 @@ create table if not exists topic_model_run (
posterior_draw_set_id text not null check (length(btrim(posterior_draw_set_id)) between 1 and 256),
posterior_draw_count integer not null check (posterior_draw_count > 0),
topic_count integer not null check (topic_count >= 2),
+ coordinate_kind_code text not null check (
+ coordinate_kind_code in ('logistic_normal_coordinate', 'plausible_value')
+ ),
inference_status_code text not null check (inference_status_code = 'posterior_topic_coordinates_not_importance'),
accepted_at timestamptz not null default now()
);
@@ -24,6 +27,20 @@ create table if not exists topic_definition (
primary key (topic_model_run_id, topic_index)
);
+create table if not exists topic_post_coordinate (
+ topic_model_run_id uuid not null references topic_model_run (topic_model_run_id) on delete cascade,
+ source_post_id uuid not null references source_post (post_id) on delete restrict,
+ topic_index integer not null,
+ posterior_draw_ordinal integer not null check (posterior_draw_ordinal >= 0),
+ coordinate_value double precision not null check (
+ coordinate_value > '-Infinity'::double precision
+ and coordinate_value < 'Infinity'::double precision
+ ),
+ primary key (topic_model_run_id, source_post_id, topic_index, posterior_draw_ordinal),
+ foreign key (topic_model_run_id, topic_index)
+ references topic_definition (topic_model_run_id, topic_index) on delete cascade
+);
+
create table if not exists topic_activity_interval (
topic_model_run_id uuid not null,
topic_index integer not null,
@@ -44,6 +61,7 @@ create table if not exists topic_lineage_relation (
target_topic_index integer,
event_time timestamptz not null,
evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'),
+ provenance_assertion_id uuid not null references provenance_assertion (assertion_id),
primary key (topic_model_run_id, relation_ordinal),
foreign key (topic_model_run_id, source_topic_index)
references topic_definition (topic_model_run_id, topic_index) on delete cascade,
@@ -75,6 +93,7 @@ create table if not exists topic_context_membership (
valid_from timestamptz not null,
valid_to timestamptz not null,
evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'),
+ provenance_assertion_id uuid not null references provenance_assertion (assertion_id),
primary key (topic_model_run_id, topic_context_membership_id),
unique (topic_model_run_id, source_post_id, dimension_code, context_id, valid_from),
foreign key (topic_model_run_id, dimension_code, context_id)
@@ -135,6 +154,20 @@ create table if not exists topic_post_context_influence (
references topic_definition (topic_model_run_id, topic_index) on delete cascade
);
+-- Preserve sorted replay when an earlier 0214 projection already exists.
+alter table topic_model_run
+ add column if not exists coordinate_kind_code text
+ check (coordinate_kind_code in ('logistic_normal_coordinate', 'plausible_value'));
+alter table topic_model_run alter column coordinate_kind_code set not null;
+alter table topic_lineage_relation
+ add column if not exists provenance_assertion_id uuid
+ references provenance_assertion (assertion_id);
+alter table topic_lineage_relation alter column provenance_assertion_id set not null;
+alter table topic_context_membership
+ add column if not exists provenance_assertion_id uuid
+ references provenance_assertion (assertion_id);
+alter table topic_context_membership alter column provenance_assertion_id set not null;
+
create index if not exists topic_activity_interval_time_idx
on topic_activity_interval (valid_from, valid_to, topic_model_run_id, topic_index);
create index if not exists topic_context_membership_post_time_idx
@@ -142,6 +175,91 @@ create index if not exists topic_context_membership_post_time_idx
create index if not exists topic_post_context_influence_read_idx
on topic_post_context_influence (topic_model_run_id, topic_index, influence_value desc);
+create index if not exists topic_lineage_relation_provenance_idx
+ on topic_lineage_relation (provenance_assertion_id);
+create index if not exists topic_context_membership_provenance_idx
+ on topic_context_membership (provenance_assertion_id);
+
+create or replace function validate_topic_post_coordinate_draw()
+returns trigger
+language plpgsql
+as $$
+declare
+ canonical_draw_count integer;
+begin
+ select posterior_draw_count
+ into canonical_draw_count
+ from topic_model_run
+ where topic_model_run_id = new.topic_model_run_id;
+
+ if new.posterior_draw_ordinal >= canonical_draw_count then
+ raise exception 'topic_post_coordinate_draw_out_of_range';
+ end if;
+ return new;
+end
+$$;
+
+drop trigger if exists topic_post_coordinate_draw_check on topic_post_coordinate;
+create trigger topic_post_coordinate_draw_check
+before insert or update on topic_post_coordinate
+for each row execute function validate_topic_post_coordinate_draw();
+
+create or replace function validate_topic_evidence_provenance()
+returns trigger
+language plpgsql
+as $$
+declare
+ canonical_relation_code text;
+begin
+ select relation_code
+ into canonical_relation_code
+ from provenance_assertion
+ where assertion_id = new.provenance_assertion_id;
+
+ if canonical_relation_code is distinct from 'prov_was_derived_from' then
+ raise exception 'topic_evidence_requires_prov_was_derived_from';
+ end if;
+ return new;
+end
+$$;
+
+drop trigger if exists topic_lineage_relation_provenance_check on topic_lineage_relation;
+create trigger topic_lineage_relation_provenance_check
+before insert or update on topic_lineage_relation
+for each row execute function validate_topic_evidence_provenance();
+
+drop trigger if exists topic_context_membership_provenance_check on topic_context_membership;
+create trigger topic_context_membership_provenance_check
+before insert or update on topic_context_membership
+for each row execute function validate_topic_evidence_provenance();
+
+create or replace function protect_topic_evidence_provenance_relation()
+returns trigger
+language plpgsql
+as $$
+begin
+ if new.relation_code is distinct from old.relation_code
+ and (
+ exists (
+ select 1 from topic_lineage_relation
+ where provenance_assertion_id = old.assertion_id
+ )
+ or exists (
+ select 1 from topic_context_membership
+ where provenance_assertion_id = old.assertion_id
+ )
+ ) then
+ raise exception 'topic_evidence_provenance_relation_is_immutable';
+ end if;
+ return new;
+end
+$$;
+
+drop trigger if exists topic_evidence_provenance_relation_protect on provenance_assertion;
+create trigger topic_evidence_provenance_relation_protect
+before update of relation_code on provenance_assertion
+for each row execute function protect_topic_evidence_provenance_relation();
+
create or replace function validate_topic_model_run_binding()
returns trigger
language plpgsql
diff --git a/migrations/0215_operations_case_milestone.sql b/migrations/0215_operations_case_milestone.sql
index 614accb7c..fd82b5da6 100644
--- a/migrations/0215_operations_case_milestone.sql
+++ b/migrations/0215_operations_case_milestone.sql
@@ -32,5 +32,29 @@ create table if not exists operations_case_missing_milestone (
on delete cascade
);
+alter table operations_case_milestone
+ drop constraint if exists operations_case_milestone_kind_type_check,
+ add constraint operations_case_milestone_kind_type_check check (
+ (case_kind_code = 'claim_investigation'
+ and milestone_type_code in ('claim_received', 'cause_confirmed'))
+ or (case_kind_code = 'rebid_handover'
+ and milestone_type_code in (
+ 'rebid_response_requested', 'rebid_decision_recorded',
+ 'handover_started', 'handover_accepted'
+ ))
+ ) not valid;
+
+alter table operations_case_missing_milestone
+ drop constraint if exists operations_case_missing_milestone_kind_type_check,
+ add constraint operations_case_missing_milestone_kind_type_check check (
+ (case_kind_code = 'claim_investigation'
+ and milestone_type_code in ('claim_received', 'cause_confirmed'))
+ or (case_kind_code = 'rebid_handover'
+ and milestone_type_code in (
+ 'rebid_response_requested', 'rebid_decision_recorded',
+ 'handover_started', 'handover_accepted'
+ ))
+ ) not valid;
+
create index if not exists operations_case_milestone_kind_time_idx
on operations_case_milestone (case_kind_code, milestone_type_code, observed_at, post_id);
diff --git a/migrations/0216_validate_operations_case_constraints.sql b/migrations/0216_validate_operations_case_constraints.sql
new file mode 100644
index 000000000..03efa980e
--- /dev/null
+++ b/migrations/0216_validate_operations_case_constraints.sql
@@ -0,0 +1,9 @@
+-- Validate ADR 0206 dashboard checks separately from their short NOT VALID installation.
+alter table operations_case_fact
+ validate constraint operations_case_fact_relation_target_kind_check;
+
+alter table operations_case_milestone
+ validate constraint operations_case_milestone_kind_type_check;
+
+alter table operations_case_missing_milestone
+ validate constraint operations_case_missing_milestone_kind_type_check;
diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py
index 7d94f519a..c85783a2e 100644
--- a/tests/test_operations_case_analysis.py
+++ b/tests/test_operations_case_analysis.py
@@ -222,6 +222,28 @@ def test_accepts_additional_grounded_fact_beyond_required_questions() -> None:
assert parse_operations_case_response(json.dumps(payload), body) is None
+def test_rejects_duplicate_required_facts() -> None:
+ """Each required question has exactly one supported or missing answer."""
+ body = "Two notices linked the same external opportunity."
+ fact = {
+ "fact_type_code": "external_relation",
+ "value_text": "Synthetic opportunity",
+ "evidence_text": body,
+ "relation_target_kind_code": "sales",
+ }
+ payload = [{
+ "case_kind_code": "external_information",
+ "summary_text": "External opportunity",
+ "evidence_text": body,
+ "facts": [fact, fact],
+ "missing_fact_type_codes": [],
+ "milestones": [],
+ "missing_milestone_type_codes": [],
+ }]
+
+ assert parse_operations_case_response(json.dumps(payload), body) is None
+
+
def test_accepts_grounded_nonrequired_fact_after_required_questions_are_complete() -> None:
"""A cited optional fact must not invalidate complete required answers."""
body = "A public notice was published and assigned to the sales team."
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index 37aed4dae..3a52b2f4e 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -238,6 +238,7 @@ async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None:
"coalesce(nullif(btrim(post.source_project_name), ''), project.primary_project_name)"
in case_query
)
+ assert "observed_at nulls last" in conn.queries[4][0]
for evidence_query in (
conn.queries[0][0],
conn.queries[1][0],
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 0474ac74d..3529fd7e1 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -112,9 +112,17 @@
/ "migrations"
/ "0215_operations_case_milestone.sql"
)
+_OPERATIONS_CASE_CONSTRAINT_VALIDATION_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0216_validate_operations_case_constraints.sql"
+)
_ANALYSIS_RUN_REGISTRY_MIGRATION = (
Path(__file__).resolve().parents[1] / "migrations" / "0018_analysis_run_registry.sql"
)
+_PROV_O_MIGRATION = (
+ Path(__file__).resolve().parents[1] / "migrations" / "0017_prov_o_standard_relations.sql"
+)
_TOPIC_CONTEXT_INFLUENCE_MIGRATION = (
Path(__file__).resolve().parents[1]
/ "migrations"
@@ -160,6 +168,7 @@ def schema_db():
try:
with conn.cursor() as cur:
cur.execute(_MIGRATION_PATH.read_text())
+ cur.execute(_PROV_O_MIGRATION.read_text())
cur.execute(_ANALYSIS_RUN_REGISTRY_MIGRATION.read_text())
cur.execute(_TOPIC_LINEAGE_KIND_MIGRATION.read_text())
cur.execute(_PROJECT_MENTION_MIGRATION.read_text())
@@ -180,6 +189,8 @@ def schema_db():
cur.execute(_OPERATIONS_CASE_MISSING_MIGRATION.read_text())
cur.execute(_TOPIC_CONTEXT_INFLUENCE_MIGRATION.read_text())
cur.execute(_OPERATIONS_EXTERNAL_RELATION_MIGRATION.read_text())
+ cur.execute(_OPERATIONS_CASE_MILESTONE_MIGRATION.read_text())
+ cur.execute(_OPERATIONS_CASE_CONSTRAINT_VALIDATION_MIGRATION.read_text())
conn.commit()
yield conn
finally:
@@ -238,10 +249,13 @@ def test_migration_applies_cleanly(schema_db) -> None:
"operations_case_classification",
"operations_case_fact",
"operations_case_missing_fact",
+ "operations_case_milestone",
+ "operations_case_missing_milestone",
"topic_model_run",
"topic_definition",
"topic_activity_interval",
"topic_lineage_relation",
+ "topic_post_coordinate",
"topic_context_definition",
"topic_context_membership",
"topic_influence_run",
@@ -250,6 +264,142 @@ def test_migration_applies_cleanly(schema_db) -> None:
assert expected <= tables
+def test_operations_case_constraints_are_validated(schema_db) -> None:
+ """Deferred dashboard checks finish validated after the follow-up migration."""
+ names = {
+ "operations_case_fact_relation_target_kind_check",
+ "operations_case_milestone_kind_type_check",
+ "operations_case_missing_milestone_kind_type_check",
+ }
+ with schema_db.cursor() as cur:
+ cur.execute(
+ "select conname, convalidated from pg_constraint where conname = any(%s)",
+ (list(names),),
+ )
+ constraints = dict(cur.fetchall())
+ assert constraints == {name: True for name in names}
+
+
+def test_operations_case_milestones_reject_cross_kind_types(schema_db) -> None:
+ """Observed and missing endpoints accept only the activity set for their case."""
+ post_id = uuid.uuid4()
+ account_id = uuid.uuid4()
+ with schema_db.cursor() as cur:
+ cur.execute(
+ """
+ insert into common_lookup_value
+ (lookup_category, lookup_code, lookup_label)
+ values ('corporate_entity_level', 'synthetic_level', 'Synthetic level'),
+ ('voc_type', 'synthetic_voc', 'Synthetic VOC'),
+ ('post_visibility', 'synthetic_visibility', 'Synthetic visibility')
+ """,
+ )
+ cur.execute(
+ "insert into corporate_entity "
+ "(corporate_entity_code, entity_name, entity_level_code) "
+ "values ('SYNTHETIC-CASE', 'Synthetic Case Entity', 'synthetic_level') "
+ "returning corporate_entity_id"
+ )
+ entity_id = cur.fetchone()[0]
+ cur.execute(
+ "insert into user_account "
+ "(user_account_id, external_subject_id, display_name, email_address) "
+ "values (%s, %s, 'Synthetic Author', %s)",
+ (str(account_id), f"synthetic-{account_id}", f"synthetic-{account_id}@example.invalid"),
+ )
+ cur.execute(
+ """
+ insert into source_post
+ (post_id, author_account_id, corporate_entity_id, post_title,
+ post_body, voc_type_code, visibility_code, created_at)
+ values (%s, %s, %s, 'Synthetic claim', 'Synthetic claim evidence',
+ 'synthetic_voc', 'synthetic_visibility', '2026-08-01T00:00:00Z')
+ """,
+ (str(post_id), str(account_id), str(entity_id)),
+ )
+ cur.execute(
+ """
+ insert into operations_case_analysis
+ (post_id, source_body_sha256, orchestrator_session_id)
+ values (%s, %s, 'synthetic-session')
+ """,
+ (str(post_id), "a" * 64),
+ )
+ cur.execute(
+ """
+ insert into operations_case_classification
+ (post_id, case_kind_code, summary_text, evidence_text,
+ evidence_post_id, evidence_input_sha256)
+ values (%s, 'claim_investigation', 'Synthetic summary',
+ 'Synthetic claim evidence', %s, %s)
+ """,
+ (str(post_id), str(post_id), "a" * 64),
+ )
+ invalid_statements = (
+ """
+ insert into operations_case_milestone
+ (post_id, case_kind_code, milestone_type_code, evidence_text,
+ evidence_post_id, evidence_input_sha256, observed_at, time_axis_code)
+ values (%s, 'claim_investigation', 'handover_started',
+ 'Synthetic claim evidence', %s, %s,
+ '2026-08-01T00:00:00Z', 'created_at')
+ """,
+ """
+ insert into operations_case_missing_milestone
+ (post_id, case_kind_code, milestone_type_code)
+ values (%s, 'claim_investigation', 'handover_started')
+ """,
+ )
+ parameters = (
+ (str(post_id), str(post_id), "a" * 64),
+ (str(post_id),),
+ )
+ for index, (statement, values) in enumerate(zip(invalid_statements, parameters)):
+ savepoint = f"invalid_milestone_kind_{index}"
+ cur.execute(f"savepoint {savepoint}")
+ with pytest.raises(psycopg2.errors.CheckViolation):
+ cur.execute(statement, values)
+ cur.execute(f"rollback to savepoint {savepoint}")
+ schema_db.rollback()
+
+
+def test_operations_case_constraint_migrations_replay(schema_db) -> None:
+ """Constraint installation and validation remain safe under startup replay."""
+ with schema_db.cursor() as cur:
+ cur.execute(_OPERATIONS_EXTERNAL_RELATION_MIGRATION.read_text())
+ cur.execute(_OPERATIONS_CASE_MILESTONE_MIGRATION.read_text())
+ cur.execute(
+ """
+ select conname, convalidated
+ from pg_constraint
+ where conname in (
+ 'operations_case_fact_relation_target_kind_check',
+ 'operations_case_milestone_kind_type_check',
+ 'operations_case_missing_milestone_kind_type_check'
+ )
+ """
+ )
+ assert dict(cur.fetchall()) == {
+ "operations_case_fact_relation_target_kind_check": False,
+ "operations_case_milestone_kind_type_check": False,
+ "operations_case_missing_milestone_kind_type_check": False,
+ }
+ cur.execute(_OPERATIONS_CASE_CONSTRAINT_VALIDATION_MIGRATION.read_text())
+ cur.execute(
+ """
+ select bool_and(convalidated)
+ from pg_constraint
+ where conname in (
+ 'operations_case_fact_relation_target_kind_check',
+ 'operations_case_milestone_kind_type_check',
+ 'operations_case_missing_milestone_kind_type_check'
+ )
+ """
+ )
+ assert cur.fetchone()[0] is True
+ schema_db.commit()
+
+
def test_topic_influence_schema_binds_exact_producer_provenance(schema_db) -> None:
"""Accepted influence runs cannot cross a TEPP run, snapshot, or cutoff."""
with schema_db.cursor() as cur:
@@ -282,6 +432,48 @@ def test_topic_influence_schema_binds_exact_producer_provenance(schema_db) -> No
foreign_keys = {row[0] for row in cur.fetchall()}
assert len(foreign_keys) == 3
+ with schema_db.cursor() as cur:
+ cur.execute(
+ """
+ select column_name, is_nullable
+ from information_schema.columns
+ where table_name in ('topic_lineage_relation', 'topic_context_membership')
+ and column_name = 'provenance_assertion_id'
+ """
+ )
+ assert cur.fetchall() == [
+ ("provenance_assertion_id", "NO"),
+ ("provenance_assertion_id", "NO"),
+ ]
+ cur.execute(
+ """
+ select count(*)
+ from pg_constraint
+ where conrelid = 'topic_post_coordinate'::regclass
+ and contype = 'f'
+ """
+ )
+ assert cur.fetchone() == (3,)
+ cur.execute(
+ """
+ select tgname
+ from pg_trigger
+ where tgname in (
+ 'topic_post_coordinate_draw_check',
+ 'topic_lineage_relation_provenance_check',
+ 'topic_context_membership_provenance_check',
+ 'topic_evidence_provenance_relation_protect'
+ )
+ and not tgisinternal
+ """
+ )
+ assert {row[0] for row in cur.fetchall()} == {
+ "topic_post_coordinate_draw_check",
+ "topic_lineage_relation_provenance_check",
+ "topic_context_membership_provenance_check",
+ "topic_evidence_provenance_relation_protect",
+ }
+
account_id = uuid.uuid4()
snapshot_id = uuid.uuid4()
run_id = uuid.uuid4()
@@ -322,10 +514,12 @@ def test_topic_influence_schema_binds_exact_producer_provenance(schema_db) -> No
tepp_schema_version, tepp_model_contract_version,
tepp_artifact_sha256, reported_source_snapshot_sha256,
reported_knowledge_cutoff, posterior_draw_set_id,
- posterior_draw_count, topic_count, inference_status_code)
+ posterior_draw_count, topic_count, coordinate_kind_code,
+ inference_status_code)
values (%s, 'tepp-mismatch', 'snapshot-mismatch',
'tepp.topic_context_posterior.v1', 'trsl-tm-v1', %s, %s,
'2026-08-01T00:00:00Z', 'draws-1', 8, 2,
+ 'logistic_normal_coordinate',
'posterior_topic_coordinates_not_importance')
""",
(str(run_id), "d" * 64, "e" * 64),
@@ -338,10 +532,12 @@ def test_topic_influence_schema_binds_exact_producer_provenance(schema_db) -> No
tepp_schema_version, tepp_model_contract_version,
tepp_artifact_sha256, reported_source_snapshot_sha256,
reported_knowledge_cutoff, posterior_draw_set_id,
- posterior_draw_count, topic_count, inference_status_code)
+ posterior_draw_count, topic_count, coordinate_kind_code,
+ inference_status_code)
values (%s, 'tepp-accepted', 'snapshot-accepted',
'tepp.topic_context_posterior.v1', 'trsl-tm-v1', %s, %s,
'2026-08-01T00:00:00Z', 'draws-1', 8, 2,
+ 'logistic_normal_coordinate',
'posterior_topic_coordinates_not_importance')
returning topic_model_run_id
""",
From e2c47f9680a20054529d49c118d354a7d9f513eb Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 02:41:06 +0900
Subject: [PATCH 040/393] docs(gaps): refresh dashboard review head
---
docs/product-technical-gap-baseline.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 0761f7175..56818bc77 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -90,7 +90,7 @@ API run independently proved the Dashboard endpoint with the isolated issuer.
### Exact open-PR boundary
At this snapshot there were 9 open PRs and 10 open issues. Exact observed heads
-were `#644 c1018a0a`, `#643 041ec13b`, `#640 6b195171`,
+were `#644 c1018a0a`, `#643 041ec13b`, `#640 90995cea` (observed parent),
`#639 aee02dca`, `#636 f7b9a65f`, `#632 a946f879`, `#631 c0022c97`,
`#629 4b4d6707`, and `#579 689a21b6`. All remain blocked on
hosted gates and/or independent review. These
@@ -117,7 +117,7 @@ context only.
| ---: | --- | --- |
| #644 | `c1018a0a` | splits conditional frontend surfaces and preserves keyed recovery; hosted gates and independent review remain required |
| #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required |
-| #640 | `6b195171` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, repairs runtime evidence-query joins/bind arity, and restores mobile GNB reachability; hosted gates and independent review must run on this exact head |
+| #640 | `90995cea` (observed parent) | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, repairs runtime evidence-query joins/bind arity, restores mobile GNB reachability, and enforces coordinate/provenance/lifecycle contracts; this baseline refresh advances the head, so hosted gates and independent review must run again |
| #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required |
| #636 | `f7b9a65f` | publishes the calibrated external-lineage contract without a redundant explicit-child filter and repairs test import hygiene; hosted gates and independent review remain required |
| #632 | `a946f879` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
From 7e63d8c2c9e7cd9999216e2097efbdf7b64907d1 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 02:42:31 +0900
Subject: [PATCH 041/393] fix(dashboard): preserve visible lifecycle starts
---
backend/app/operations_dashboard.py | 2 +-
tests/test_operations_dashboard.py | 22 +++++++++++++++++++++-
2 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index a7d826ebd..7a4dbf622 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -461,7 +461,7 @@ def _project_lifecycles(
elapsed_seconds = int((datetime.fromisoformat(end["observed_at"]) - datetime.fromisoformat(start["observed_at"])).total_seconds())
status_code = "resolved"
next_action = "시작·종료 Event 근거를 열어 경과 시간을 검토하세요."
- elif start and end_code in missing_milestones:
+ elif start:
elapsed_seconds = None
status_code = "open"
next_action = f"{MILESTONE_TYPE_LABELS[end_code]} Event 근거를 연결하세요."
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index 3a52b2f4e..30dc72664 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -4,7 +4,7 @@
import pytest
-from backend.app.operations_dashboard import fetch_operations_dashboard
+from backend.app.operations_dashboard import _project_lifecycles, fetch_operations_dashboard
class _Connection:
@@ -91,6 +91,26 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
]
+def test_projected_start_with_unavailable_end_remains_open() -> None:
+ """A hidden end citation cannot make an observed start look absent."""
+ start = {
+ "milestone_type_code": "claim_received",
+ "milestone_type_label": "클레임 접수",
+ "evidence_text": "Synthetic claim received",
+ "evidence_post_id": "synthetic-start",
+ "observed_at": "2026-08-01T09:00:00+00:00",
+ "time_axis_code": "event_occurred_at",
+ "time_axis_label": "Event 발생일",
+ }
+
+ lifecycle = _project_lifecycles("claim_investigation", [start], set())[0]
+
+ assert lifecycle["status_code"] == "open"
+ assert lifecycle["start_milestone"] == start
+ assert lifecycle["end_milestone"] is None
+ assert lifecycle["next_action_text"] == "원인 확정 Event 근거를 연결하세요."
+
+
@pytest.mark.anyio
async def test_dashboard_uses_abac_event_clock_and_persisted_evidence() -> None:
"""Counts and cases share the exact authorized event-time population."""
From 1cc3cd2f0e614d305f8ecfa5afe2901fee869c63 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 02:50:25 +0900
Subject: [PATCH 042/393] docs(operability): record constrained dashboard rerun
---
docs/operability/http-concurrency-evidence.md | 11 +++++++++++
docs/product-technical-gap-baseline.md | 2 +-
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md
index eca0b8a6a..bca6d5b3f 100644
--- a/docs/operability/http-concurrency-evidence.md
+++ b/docs/operability/http-concurrency-evidence.md
@@ -76,6 +76,17 @@ tests now assert the join and bind arity; the distribution above is the clean
rerun. This is synthetic candidate evidence, not protected-main evidence or a
capacity/SLO claim.
+After the normalized topic-coordinate/provenance and lifecycle constraints were
+added, candidate `7e63d8c2` replayed migrations through `0216` on the retained
+synthetic volume and passed the real-PostgreSQL Dashboard contract. Its clean
+4-VU/30-second rerun completed 345 iterations, 1,382 requests, and 1,380/1,380
+checks with zero request failures. HTTP duration was 255.55 ms average,
+187.54 ms median, and 593.53 ms p95; the reader metric was 275.26 ms average
+and 637.94 ms p95. Ask enqueue took 791.12 ms and polling p95 was 425.66 ms.
+The host was still completing the Keycloak/Quarkus cold start immediately
+before this run, so the distribution is retained as correctness/concurrency
+evidence and is not compared as a performance regression or SLO.
+
## Current-main verification record
On 2026-08-25, a worktree based on protected-main commit `48f013a2` passed
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 56818bc77..6bee0ebcc 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -408,7 +408,7 @@ this file per §3.5 of the prior snapshot).
| Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push |
| Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc |
| Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence |
-| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `b045a6e5` completed a synthetic authenticated 4-VU/30-second k6 run: 4,962 requests, zero failures, HTTP p95 181.76 ms, and reader p95 197.25 ms. The first run exposed and the candidate repaired Dashboard evidence-join and bind-arity defects. This is local candidate evidence, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence |
+| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `b045a6e5` completed a synthetic authenticated 4-VU/30-second k6 run with 4,962 requests and zero failures. After schema/lifecycle repairs, `7e63d8c2` replayed through migration 0216, passed the real-PostgreSQL Dashboard contract, and completed 1,382 requests with zero failures (HTTP p95 593.53 ms; reader p95 637.94 ms) while the host remained cold-start loaded. These are local correctness/concurrency observations, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence |
| Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing |
| Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts |
| Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence |
From 1fb65c2e4c8fe42a43db62f380594c7e1f0f7eb7 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 03:03:06 +0900
Subject: [PATCH 043/393] fix(frontend): pass Keyverse build arguments
---
frontend/Dockerfile | 8 ++++----
tests/test_frontend_container_contract.py | 21 +++++++++++++++++++++
2 files changed, 25 insertions(+), 4 deletions(-)
create mode 100644 tests/test_frontend_container_contract.py
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 5f2e1eb9a..978eb2616 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -6,11 +6,11 @@ RUN pnpm install --frozen-lockfile
COPY . .
# Vite bakes VITE_* vars in at build time, not runtime -- build args let
# docker-compose pass through its own (possibly operator-overridden) ports.
-ARG VITE_KEYCLOAK_ISSUER
-ARG VITE_KEYCLOAK_CLIENT_ID
+ARG VITE_KEYVERSE_ISSUER
+ARG VITE_KEYVERSE_CLIENT_ID
ARG VITE_BACKEND_BASE_URL
-ENV VITE_KEYCLOAK_ISSUER=${VITE_KEYCLOAK_ISSUER} \
- VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID} \
+ENV VITE_KEYVERSE_ISSUER=${VITE_KEYVERSE_ISSUER} \
+ VITE_KEYVERSE_CLIENT_ID=${VITE_KEYVERSE_CLIENT_ID} \
VITE_BACKEND_BASE_URL=${VITE_BACKEND_BASE_URL}
RUN pnpm run build
diff --git a/tests/test_frontend_container_contract.py b/tests/test_frontend_container_contract.py
new file mode 100644
index 000000000..8970f34f7
--- /dev/null
+++ b/tests/test_frontend_container_contract.py
@@ -0,0 +1,21 @@
+"""Static contracts for frontend Compose build-time configuration."""
+
+from pathlib import Path
+
+
+_ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_compose_and_frontend_image_share_vite_build_arguments() -> None:
+ """Compose overrides must reach the names Vite reads during its build."""
+ compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
+ dockerfile = (_ROOT / "frontend" / "Dockerfile").read_text(encoding="utf-8")
+
+ for variable in (
+ "VITE_KEYVERSE_ISSUER",
+ "VITE_KEYVERSE_CLIENT_ID",
+ "VITE_BACKEND_BASE_URL",
+ ):
+ assert f"{variable}:" in compose
+ assert f"ARG {variable}" in dockerfile
+ assert f"{variable}=${{{variable}}}" in dockerfile
From 175db369e772ea531d072ad260a2f9670981ed09 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 03:07:11 +0900
Subject: [PATCH 044/393] docs: record authenticated dashboard acceptance
---
docs/product-technical-gap-baseline.md | 33 ++++++++++++++++----------
1 file changed, 20 insertions(+), 13 deletions(-)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 6bee0ebcc..7d32d15b3 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -2,17 +2,17 @@
> Dashboard delivery snapshot: 2026-08-26 KST (2026-08-25 UTC; latest exact-head fetch). Protected `main` was
> `04e6b610655d0db91d5f7ba9486bdda1440e0b19`. Dashboard candidate head
-> `b045a6e5` has local synthetic runtime evidence; this is not
+> `1fb65c2e` (observed parent) has local synthetic runtime evidence; this is not
> protected-main release evidence.
## Operations Dashboard PRD/TRD traceability
| Requirement | Evidence contract | Delivery state |
|---|---|---|
-| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification and `claim_received` → `cause_confirmed` milestones with cited spans and observed source clocks | Stacked candidate reports open/resolved/evidence-missing counts and exact elapsed time only for paired observed endpoints; every required answer and endpoint is cited or explicitly missing; authenticated runtime acceptance and corpus re-analysis pending |
-| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated runtime acceptance and corpus re-analysis pending |
-| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated runtime acceptance pending |
-| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated runtime acceptance pending |
+| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification and `claim_received` → `cause_confirmed` milestones with cited spans and observed source clocks | Stacked candidate reports open/resolved/evidence-missing counts and exact elapsed time only for paired observed endpoints; every required answer and endpoint is cited or explicitly missing; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending |
+| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending |
+| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated synthetic runtime passed with the honest zero-result state |
+| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated synthetic runtime passed |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending |
| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending |
@@ -83,15 +83,22 @@ hid the entire GNB despite having no drawer implementation. Candidate
`6b195171` keeps the same semantic navigation in a keyboard-accessible
horizontal viewport. The 390×844 rerender showed the GNB, one `main`, zero
unnamed controls, no document-level horizontal overflow, and visible keyboard
-focus. The isolated frontend/backend issuer mismatch returned Dashboard 401,
-so populated authenticated data remains a separate acceptance item; the k6
-API run independently proved the Dashboard endpoint with the isolated issuer.
+focus. Candidate `1fb65c2e` fixed the root cause of the isolated 401: Compose
+passed canonical `VITE_KEYVERSE_*` build arguments while the frontend image
+consumed different names and silently compiled the default issuer. A fresh OIDC
+browser context then rendered the authenticated synthetic Dashboard at
+1440×1000 and 390×844 with 27 total posts, one classified Event/post, 24
+pending analyses, zero failures, and honest unavailable TEPP/fast-mlsirm
+producer contracts. Both viewports had one `main`, one navigation landmark,
+zero unnamed controls, visible first-tab focus, no document horizontal
+overflow, no console errors, and no HTTP 4xx/5xx. Screenshots remain local and
+uncommitted.
### Exact open-PR boundary
At this snapshot there were 9 open PRs and 10 open issues. Exact observed heads
-were `#644 c1018a0a`, `#643 041ec13b`, `#640 90995cea` (observed parent),
-`#639 aee02dca`, `#636 f7b9a65f`, `#632 a946f879`, `#631 c0022c97`,
+were `#644 c1018a0a`, `#643 041ec13b`, `#640 1fb65c2e`,
+`#639 aee02dca`, `#636 f7b9a65f`, `#632 8b322992`, `#631 c0022c97`,
`#629 4b4d6707`, and `#579 689a21b6`. All remain blocked on
hosted gates and/or independent review. These
observations are not merge readiness. Re-fetch exact heads,
@@ -117,10 +124,10 @@ context only.
| ---: | --- | --- |
| #644 | `c1018a0a` | splits conditional frontend surfaces and preserves keyed recovery; hosted gates and independent review remain required |
| #643 | `041ec13b` | shares token-backed success/unavailable/retry status notices for the Calendar surface; hosted gates and independent review remain required |
-| #640 | `90995cea` (observed parent) | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, repairs runtime evidence-query joins/bind arity, restores mobile GNB reachability, and enforces coordinate/provenance/lifecycle contracts; this baseline refresh advances the head, so hosted gates and independent review must run again |
+| #640 | `1fb65c2e` | quantifies dashboard cases, persists explicit missing facts and observed lifecycle milestones, consumes producer-owned topic influence without local arithmetic, repairs runtime evidence-query joins/bind arity and canonical Keyverse image build arguments, restores mobile GNB reachability, and enforces coordinate/provenance/lifecycle contracts; authenticated synthetic desktop/mobile acceptance passed, while hosted gates and independent review remain required |
| #639 | `aee02dca` | repairs Running-action, Compose, and TEPP configuration contracts; hosted gates and independent review remain required |
| #636 | `f7b9a65f` | publishes the calibrated external-lineage contract without a redundant explicit-child filter and repairs test import hygiene; hosted gates and independent review remain required |
-| #632 | `a946f879` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
+| #632 | `8b322992` | preserves graph-fact source provenance and authorization with a static landing-query contract and shared RankWeave disable switch; hosted gates and independent review remain required |
| #631 | `c0022c97` | decomposes ADR gaps and queue baseline; hosted gates and independent review remain required |
| #629 | `4b4d6707` | releases provider work from database leases and bounds landing reads; hosted gates and independent review remain required |
| #579 | `689a21b6` | delegates leftover interaction-map arithmetic to fast-mlsirm and persists only the consumer projection; hosted gates and independent review remain required |
@@ -408,7 +415,7 @@ this file per §3.5 of the prior snapshot).
| Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push |
| Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc |
| Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence |
-| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `b045a6e5` completed a synthetic authenticated 4-VU/30-second k6 run with 4,962 requests and zero failures. After schema/lifecycle repairs, `7e63d8c2` replayed through migration 0216, passed the real-PostgreSQL Dashboard contract, and completed 1,382 requests with zero failures (HTTP p95 593.53 ms; reader p95 637.94 ms) while the host remained cold-start loaded. These are local correctness/concurrency observations, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence |
+| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `b045a6e5` completed a synthetic authenticated 4-VU/30-second k6 run with 4,962 requests and zero failures. After schema/lifecycle repairs, `7e63d8c2` replayed through migration 0216, passed the real-PostgreSQL Dashboard contract, and completed 1,382 requests with zero failures (HTTP p95 593.53 ms; reader p95 637.94 ms) while the host remained cold-start loaded. Candidate `1fb65c2e` additionally passed fresh authenticated desktop/mobile browser acceptance without HTTP or console failures. These are local correctness/concurrency observations, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence |
| Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing |
| Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts |
| Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence |
From 31d05313651ecfaa75877d7e046cba309be83e31 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 03:30:49 +0900
Subject: [PATCH 045/393] test: remove unused datetime imports
---
tests/test_operations_case_analysis.py | 1 -
tests/test_operations_dashboard.py | 2 +-
2 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py
index c85783a2e..a17e5a898 100644
--- a/tests/test_operations_case_analysis.py
+++ b/tests/test_operations_case_analysis.py
@@ -1,7 +1,6 @@
"""Operational case semantic-response contract tests."""
import json
-from datetime import UTC, datetime
from lineageweave.operations_case_analysis import (
OperationsEvidenceSource,
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index 30dc72664..840a6dedc 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -1,6 +1,6 @@
"""Focused tests for the operational dashboard evidence projection."""
-from datetime import UTC, date, datetime, timezone
+from datetime import date, datetime, timezone
import pytest
From 4bed57a78b13be061860a34c999b0fad7517c193 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 03:47:39 +0900
Subject: [PATCH 046/393] test(ask): isolate asynchronous queue settlement
---
CHANGELOG.md | 7 ++++
backend/app/main.py | 22 ++++++------
backend/tests/test_api.py | 17 +++++----
docs/product-technical-gap-baseline.md | 9 +++++
pyproject.toml | 2 +-
uv.lock | 48 ++++++++++++++++++--------
6 files changed, 72 insertions(+), 33 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8fe0fe635..3a1c4e20e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,13 @@ All notable changes to this project are documented here. Format follows
## [Unreleased]
+### Changed
+
+- Async Ask queue tests now isolate queue settlement from semantic retrieval,
+ and the development test stack follows Starlette's maintained `httpx2`
+ `TestClient` contract. FastAPI 422 responses use the RFC 9110 constant, so
+ deprecation failures are repaired rather than suppressed.
+
### Added
- ADR 0210's Dashboard consumer now persists a normalized, exact-provenance
diff --git a/backend/app/main.py b/backend/app/main.py
index 8e2534017..9a8ad5045 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1232,7 +1232,7 @@ async def resolve_customer_master_hint(
) from exc
if resolution is None:
raise HTTPException(
- status.HTTP_422_UNPROCESSABLE_ENTITY,
+ status.HTTP_422_UNPROCESSABLE_CONTENT,
"this hint could not be resolved to a corroborated organization name",
)
return resolution
@@ -1591,7 +1591,7 @@ async def read_post(
as_of_clock = parse_as_of_clock(as_of)
except ValueError as exc:
raise HTTPException(
- status.HTTP_422_UNPROCESSABLE_ENTITY,
+ status.HTTP_422_UNPROCESSABLE_CONTENT,
"as_of must be an ISO-8601 timestamp. Use the run cutoff, "
"then compare the known body with the live body.",
) from exc
@@ -2215,7 +2215,7 @@ async def read_ontology_neighborhood(
try:
cutoff_clock = parse_as_of_clock(knowledge_cutoff)
except ValueError as exc:
- raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
try:
async with pool.acquire() as conn:
neighborhood = await visible_ontology_neighborhood(
@@ -2631,7 +2631,7 @@ async def compare_period_groupings(
try:
parse_period_code(period_code)
except ValueError as exc:
- raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
async with pool.acquire() as conn:
rows = await fetch_period_comparison(conn, period_code)
demo_entity_ids: set[str] = set()
@@ -2687,7 +2687,7 @@ async def list_period_reports(
"""Available calibrated periods for one grouping kind (FIPC trend)."""
_require_post_read(account)
if grouping_kind not in GROUPING_KINDS:
- raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "unknown grouping_kind")
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind")
async with pool.acquire() as conn:
summaries = await list_period_report_summaries(conn, grouping_kind)
demo_entity_ids: set[str] = set()
@@ -2717,11 +2717,11 @@ async def read_period_reports(
"""Calibrated IRT scores for one grouping kind and calendar period."""
_require_post_read(account)
if grouping_kind not in GROUPING_KINDS:
- raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "unknown grouping_kind")
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind")
try:
parse_period_code(period_code)
except ValueError as exc:
- raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
async with pool.acquire() as conn:
reports = await fetch_period_reports(conn, grouping_kind, period_code)
demo_entity_ids: set[str] = set()
@@ -2794,11 +2794,11 @@ async def rebuild_period_report_endpoint(
"""Refit or FIPC-score every group in the period. post_admin only."""
_require_post_admin(account)
if grouping_kind not in GROUPING_KINDS:
- raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "unknown grouping_kind")
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind")
try:
parse_period_code(period_code)
except ValueError as exc:
- raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
async with pool.acquire() as conn:
async with conn.transaction():
reports = await rebuild_period_reports(conn, grouping_kind, period_code)
@@ -3003,7 +3003,7 @@ async def chat_about_post(
question = request.question.strip()
if not question:
raise HTTPException(
- status.HTTP_422_UNPROCESSABLE_ENTITY, "question is required"
+ status.HTTP_422_UNPROCESSABLE_CONTENT, "question is required"
)
post = await _load_visible_post(post_id, account, pool)
post_metadata = build_post_llm_metadata(post_id, post)
@@ -3596,7 +3596,7 @@ async def read_calendar(
_require_post_read(account)
if (window_start is None) ^ (window_end is None):
raise HTTPException(
- status.HTTP_422_UNPROCESSABLE_ENTITY,
+ status.HTTP_422_UNPROCESSABLE_CONTENT,
"window_start and window_end must be supplied together",
)
settings = load_settings()
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 5538f465b..cf0d7d512 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -5088,18 +5088,21 @@ def test_ask_queues_a_job_and_polls_it_to_a_settled_answer(
"""
import time as _time
- from lineageweave.post_chat import ChatAnswer
-
class _FakeChatClient:
available = True
- def answer(self, question, sources): # noqa: ARG002 - contract shape
- return ChatAnswer(
- answer_text="A settled asynchronous answer.",
- cited_post_ids=(sources[0].post_id,),
- )
+ async def _fake_compute_answer(*_args, **_kwargs):
+ return {
+ "answer_text": "A settled asynchronous answer.",
+ "cited_post_ids": [seeded_db["public_post_id"]],
+ "lineage_graph": {"nodes": [], "edges": [], "truncated": False},
+ "cited_post_images": [],
+ }
monkeypatch.setattr("backend.app.main._post_chat_client", lambda **_kwargs: _FakeChatClient())
+ monkeypatch.setattr(
+ "backend.app.global_ask_queue.compute_global_ask_answer", _fake_compute_answer
+ )
headers = {"Authorization": f"Bearer {demo_analyst_token}"}
submitted = client.post(
"/api/ask", json={"question": "What happened with the public post?"}, headers=headers
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 7d32d15b3..bba660f5a 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -93,6 +93,15 @@ producer contracts. Both viewports had one `main`, one navigation landmark,
zero unnamed controls, visible first-tab focus, no document horizontal
overflow, no console errors, and no HTTP 4xx/5xx. Screenshots remain local and
uncommitted.
+The same candidate then ran the complete Python/backend suite with
+`DeprecationWarning` promoted to an error: 1,372 tests passed and 17 declared
+integration/provider tests skipped. The run exposed one stale async-queue test
+that implicitly depended on unavailable semantic retrieval; the repaired test
+injects the queue computation boundary, while dedicated retrieval tests retain
+the no-keyword, fail-closed embedding contract. Starlette's maintained
+`httpx2` TestClient dependency and FastAPI's RFC 9110 422 constant remove the
+observed deprecations without suppression. This remains local exact-head
+regression evidence, not hosted-gate or protected-main evidence.
### Exact open-PR boundary
diff --git a/pyproject.toml b/pyproject.toml
index e3b7a5b18..d8fb3a5bd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,11 +37,11 @@ dev = [
"coverage>=7.6",
"pyjwt[crypto]>=2.8.0",
"pytest>=8.0",
- "httpx>=0.27.0",
# Closed-world SHACL validation of docs/ontology/lineageweave-kg.ttl
# against lineageweave-kg-shapes.ttl (ADR 0207 decision 10). Pure
# Python; OWL-RL reasoning included.
"pyshacl>=0.26.0",
+ "httpx2>=2.12.0",
]
backend = [
"fastapi>=0.115.0",
diff --git a/uv.lock b/uv.lock
index 6190681ba..50586f675 100644
--- a/uv.lock
+++ b/uv.lock
@@ -523,16 +523,16 @@ wheels = [
]
[[package]]
-name = "httpcore"
-version = "1.0.9"
+name = "httpcore2"
+version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "certifi" },
{ name = "h11" },
+ { name = "truststore" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" },
]
[[package]]
@@ -572,18 +572,29 @@ wheels = [
]
[[package]]
-name = "httpx"
-version = "0.28.1"
+name = "httpx2"
+version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "anyio" },
- { name = "certifi" },
- { name = "httpcore" },
+ { name = "anyio", marker = "sys_platform != 'emscripten'" },
+ { name = "httpcore2", marker = "sys_platform != 'emscripten'" },
+ { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
{ name = "idna" },
+ { name = "truststore", marker = "sys_platform != 'emscripten'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" },
+]
+
+[[package]]
+name = "httpx2-jsfetch"
+version = "1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
]
[[package]]
@@ -631,7 +642,7 @@ backend = [
]
dev = [
{ name = "coverage" },
- { name = "httpx" },
+ { name = "httpx2" },
{ name = "psycopg2-binary" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyshacl" },
@@ -646,7 +657,7 @@ requires-dist = [
{ name = "cryptography", specifier = ">=42.0" },
{ name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=d025b7d237d8db7ca97a5611606c6285d5870895" },
{ name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.115.0" },
- { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
+ { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.12.0" },
{ name = "opentelemetry-api", specifier = ">=1.30.0" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.30.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.30.0" },
@@ -1272,6 +1283,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/e0/ffbc0d61d68304602120998a5d660c8108464064bdedc814dc4be8410425/threadweave-0.1.0-py3-none-any.whl", hash = "sha256:03c31fa21873a9493687d81eab4ec067bf169dade7cff077b80df46fd0db3aaf", size = 14967, upload-time = "2026-07-12T03:59:57.088Z" },
]
+[[package]]
+name = "truststore"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
+]
+
[[package]]
name = "typing-extensions"
version = "4.16.0"
From 0859f07c35ed75bbc81ae120e3b7b4465c0c2165 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 04:07:43 +0900
Subject: [PATCH 047/393] fix(dashboard): align topic readiness windows
---
backend/app/operations_dashboard.py | 16 +++++++++++++---
tests/test_operations_dashboard.py | 16 ++++++++++++++++
2 files changed, 29 insertions(+), 3 deletions(-)
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index 7a4dbf622..40093fe3a 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -501,7 +501,8 @@ async def _fetch_topic_context_dashboard(
readiness = await conn.fetchrow(
f"""
with visible_post as (
- select post.post_id
+ select post.post_id,
+ coalesce(post.event_occurred_at, post.created_at) as occurred_at
from source_post post
where {visible_post_sql}
)
@@ -513,7 +514,9 @@ async def _fetch_topic_context_dashboard(
join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id
join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id
join visible_post on visible_post.post_id = membership.source_post_id
- where {authorized_model_scope}
+ where visible_post.occurred_at >= membership.valid_from
+ and visible_post.occurred_at < membership.valid_to
+ and {authorized_model_scope}
) as tepp_posterior_persisted,
exists (
select 1
@@ -526,7 +529,14 @@ async def _fetch_topic_context_dashboard(
join analysis_run analysis on analysis.analysis_run_id = model.analysis_run_id
join analysis_run_scope scope on scope.analysis_run_id = analysis.analysis_run_id
join visible_post on visible_post.post_id = membership.source_post_id
- where {authorized_model_scope}
+ join topic_activity_interval activity
+ on activity.topic_model_run_id = influence.topic_model_run_id
+ and activity.topic_index = influence.topic_index
+ and visible_post.occurred_at >= activity.valid_from
+ and visible_post.occurred_at < activity.valid_to
+ where visible_post.occurred_at >= membership.valid_from
+ and visible_post.occurred_at < membership.valid_to
+ and {authorized_model_scope}
) as fast_mlsirm_influence_persisted
""",
*args,
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index 840a6dedc..5bf0314b2 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -360,6 +360,22 @@ async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
assert result["topic_context"]["reason_code"] == "fast_mlsirm_influence_not_persisted"
assert result["topic_context"]["topics"] == []
+
+@pytest.mark.anyio
+async def test_topic_readiness_uses_projection_temporal_windows() -> None:
+ """Readiness cannot count influence rows the projection must reject by time."""
+ conn = _Connection()
+ await fetch_operations_dashboard(conn, [])
+ readiness_query = next(
+ query for query, _args in conn.queries if "tepp_posterior_persisted" in query
+ )
+ assert "coalesce(post.event_occurred_at, post.created_at) as occurred_at" in readiness_query
+ assert "visible_post.occurred_at >= membership.valid_from" in readiness_query
+ assert "visible_post.occurred_at < membership.valid_to" in readiness_query
+ assert "join topic_activity_interval activity" in readiness_query
+ assert "visible_post.occurred_at >= activity.valid_from" in readiness_query
+ assert "visible_post.occurred_at < activity.valid_to" in readiness_query
+
@pytest.mark.anyio
async def test_external_scope_is_bound_in_every_dashboard_query() -> None:
"""The external destination restricts data at the API query boundary."""
From 24c76c10ec442f534a4a3191e1165fa0ebd6b53c Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 04:09:32 +0900
Subject: [PATCH 048/393] fix(dashboard): preserve external metric denominator
---
backend/app/operations_dashboard.py | 8 +----
pyproject.toml | 2 +-
tests/test_operations_dashboard.py | 12 +++++---
uv.lock | 48 +++++++++--------------------
4 files changed, 24 insertions(+), 46 deletions(-)
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index 40093fe3a..fb281e203 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -169,12 +169,6 @@ async def fetch_operations_dashboard(
select post.post_id
from source_post post
where {visible}
- and ($5::boolean is false or exists (
- select 1
- from operations_case_classification scoped_classification
- where scoped_classification.post_id = post.post_id
- and scoped_classification.case_kind_code = 'external_information'
- ))
), classified as (
select classification.post_id, classification.case_kind_code
from operations_case_classification classification
@@ -208,7 +202,7 @@ async def fetch_operations_dashboard(
and job.status_code = 'post_content_ingestion_failed'
)) as failed_analysis_count
""",
- *args,
+ *args[:4],
)
case_rows = await conn.fetch(
f"""
diff --git a/pyproject.toml b/pyproject.toml
index d8fb3a5bd..4685e1f97 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,7 +41,7 @@ dev = [
# against lineageweave-kg-shapes.ttl (ADR 0207 decision 10). Pure
# Python; OWL-RL reasoning included.
"pyshacl>=0.26.0",
- "httpx2>=2.12.0",
+ "httpx>=0.27.0",
]
backend = [
"fastapi>=0.115.0",
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index 5bf0314b2..61fa42e9f 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -377,15 +377,19 @@ async def test_topic_readiness_uses_projection_temporal_windows() -> None:
assert "visible_post.occurred_at < activity.valid_to" in readiness_query
@pytest.mark.anyio
-async def test_external_scope_is_bound_in_every_dashboard_query() -> None:
- """The external destination restricts data at the API query boundary."""
+async def test_external_scope_filters_cases_without_changing_total_population() -> None:
+ """External-only cases retain whole authorized-corpus metric denominators."""
conn = _Connection()
await fetch_operations_dashboard(
conn, ["corp"], ["pu"], date(2026, 8, 1), date(2026, 8, 31), external_only=True
)
assert conn.queries
- assert all("$5::boolean" in query for query, _ in conn.queries)
- assert all(args[-1] is True for _, args in conn.queries)
+ metrics_query, metrics_args = conn.queries[0]
+ assert "$5::boolean" not in metrics_query
+ assert len(metrics_args) == 4
+ for query, args in conn.queries[1:]:
+ assert "$5::boolean" in query
+ assert args[-1] is True
@pytest.mark.anyio
diff --git a/uv.lock b/uv.lock
index 50586f675..6190681ba 100644
--- a/uv.lock
+++ b/uv.lock
@@ -523,16 +523,16 @@ wheels = [
]
[[package]]
-name = "httpcore2"
-version = "2.12.0"
+name = "httpcore"
+version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "certifi" },
{ name = "h11" },
- { name = "truststore" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
@@ -572,29 +572,18 @@ wheels = [
]
[[package]]
-name = "httpx2"
-version = "2.12.0"
+name = "httpx"
+version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "anyio", marker = "sys_platform != 'emscripten'" },
- { name = "httpcore2", marker = "sys_platform != 'emscripten'" },
- { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
{ name = "idna" },
- { name = "truststore", marker = "sys_platform != 'emscripten'" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" },
-]
-
-[[package]]
-name = "httpx2-jsfetch"
-version = "1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
@@ -642,7 +631,7 @@ backend = [
]
dev = [
{ name = "coverage" },
- { name = "httpx2" },
+ { name = "httpx" },
{ name = "psycopg2-binary" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyshacl" },
@@ -657,7 +646,7 @@ requires-dist = [
{ name = "cryptography", specifier = ">=42.0" },
{ name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=d025b7d237d8db7ca97a5611606c6285d5870895" },
{ name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.115.0" },
- { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.12.0" },
+ { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
{ name = "opentelemetry-api", specifier = ">=1.30.0" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.30.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.30.0" },
@@ -1283,15 +1272,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/e0/ffbc0d61d68304602120998a5d660c8108464064bdedc814dc4be8410425/threadweave-0.1.0-py3-none-any.whl", hash = "sha256:03c31fa21873a9493687d81eab4ec067bf169dade7cff077b80df46fd0db3aaf", size = 14967, upload-time = "2026-07-12T03:59:57.088Z" },
]
-[[package]]
-name = "truststore"
-version = "0.10.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
-]
-
[[package]]
name = "typing-extensions"
version = "4.16.0"
From 87152ff4717af22d15e28c2c9a24bdfe39edf591 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 04:11:32 +0900
Subject: [PATCH 049/393] fix(dashboard): align empty topic contract state
---
backend/app/operations_dashboard.py | 7 ++++++-
tests/test_operations_dashboard.py | 20 ++++++++++++++++++++
2 files changed, 26 insertions(+), 1 deletion(-)
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index fb281e203..2f66db4a5 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -634,6 +634,11 @@ async def _fetch_topic_context_dashboard(
)
if not rows:
tepp_ready = bool(readiness and readiness["tepp_posterior_persisted"])
+ # The readiness query can see an accepted influence row from a
+ # different selected run than the projection query. In an empty
+ # projection, report fast-mlsirm as unavailable for this exact
+ # visible/time window rather than claiming a persisted contract.
+ fast_mlsirm_ready = False
return {
"status_code": "unavailable",
"reason_code": (
@@ -657,7 +662,7 @@ async def _fetch_topic_context_dashboard(
"schema_version": "fast_mlsirm.topic_context_influence.v1",
"state_code": (
"persisted"
- if readiness and readiness["fast_mlsirm_influence_persisted"]
+ if fast_mlsirm_ready
else "not_persisted"
),
},
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index 61fa42e9f..cb0dd6f55 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -361,6 +361,26 @@ async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
assert result["topic_context"]["topics"] == []
+@pytest.mark.anyio
+async def test_empty_projection_does_not_claim_fast_result_persisted() -> None:
+ """An empty visible projection must not contradict its contract state."""
+
+ class ReadyButEmptyConnection(_Connection):
+ async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
+ if "tepp_posterior_persisted" in query:
+ self.queries.append((query, args))
+ return {
+ "tepp_posterior_persisted": True,
+ "fast_mlsirm_influence_persisted": True,
+ }
+ return await super().fetchrow(query, *args)
+
+ result = await fetch_operations_dashboard(ReadyButEmptyConnection(), [])
+ contracts = result["topic_context"]["required_contracts"]
+ assert contracts[0]["state_code"] == "persisted"
+ assert contracts[1]["state_code"] == "not_persisted"
+
+
@pytest.mark.anyio
async def test_topic_readiness_uses_projection_temporal_windows() -> None:
"""Readiness cannot count influence rows the projection must reject by time."""
From 2d50fa01abcfd80ab8caeffdcc842f1d8ea8113f Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 04:11:33 +0900
Subject: [PATCH 050/393] fix(test): retain Starlette httpx2 transport
---
pyproject.toml | 2 +-
uv.lock | 48 ++++++++++++++++++++++++++++++++++--------------
2 files changed, 35 insertions(+), 15 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 4685e1f97..d8fb3a5bd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,7 +41,7 @@ dev = [
# against lineageweave-kg-shapes.ttl (ADR 0207 decision 10). Pure
# Python; OWL-RL reasoning included.
"pyshacl>=0.26.0",
- "httpx>=0.27.0",
+ "httpx2>=2.12.0",
]
backend = [
"fastapi>=0.115.0",
diff --git a/uv.lock b/uv.lock
index 6190681ba..50586f675 100644
--- a/uv.lock
+++ b/uv.lock
@@ -523,16 +523,16 @@ wheels = [
]
[[package]]
-name = "httpcore"
-version = "1.0.9"
+name = "httpcore2"
+version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "certifi" },
{ name = "h11" },
+ { name = "truststore" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" },
]
[[package]]
@@ -572,18 +572,29 @@ wheels = [
]
[[package]]
-name = "httpx"
-version = "0.28.1"
+name = "httpx2"
+version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "anyio" },
- { name = "certifi" },
- { name = "httpcore" },
+ { name = "anyio", marker = "sys_platform != 'emscripten'" },
+ { name = "httpcore2", marker = "sys_platform != 'emscripten'" },
+ { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
{ name = "idna" },
+ { name = "truststore", marker = "sys_platform != 'emscripten'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" },
+]
+
+[[package]]
+name = "httpx2-jsfetch"
+version = "1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
]
[[package]]
@@ -631,7 +642,7 @@ backend = [
]
dev = [
{ name = "coverage" },
- { name = "httpx" },
+ { name = "httpx2" },
{ name = "psycopg2-binary" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyshacl" },
@@ -646,7 +657,7 @@ requires-dist = [
{ name = "cryptography", specifier = ">=42.0" },
{ name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=d025b7d237d8db7ca97a5611606c6285d5870895" },
{ name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.115.0" },
- { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
+ { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.12.0" },
{ name = "opentelemetry-api", specifier = ">=1.30.0" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.30.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.30.0" },
@@ -1272,6 +1283,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/e0/ffbc0d61d68304602120998a5d660c8108464064bdedc814dc4be8410425/threadweave-0.1.0-py3-none-any.whl", hash = "sha256:03c31fa21873a9493687d81eab4ec067bf169dade7cff077b80df46fd0db3aaf", size = 14967, upload-time = "2026-07-12T03:59:57.088Z" },
]
+[[package]]
+name = "truststore"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
+]
+
[[package]]
name = "typing-extensions"
version = "4.16.0"
From 361641ece89f6b8d030188913fd13a41ab9c89df Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 07:38:35 +0900
Subject: [PATCH 051/393] fix(packaging): adopt SPDX license metadata
---
CHANGELOG.d/2.20.0-backend-contract-regressions.md | 2 +-
pyproject.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.d/2.20.0-backend-contract-regressions.md b/CHANGELOG.d/2.20.0-backend-contract-regressions.md
index a391d991b..02c90e63b 100644
--- a/CHANGELOG.d/2.20.0-backend-contract-regressions.md
+++ b/CHANGELOG.d/2.20.0-backend-contract-regressions.md
@@ -1,3 +1,3 @@
### Fixed
-- Restored the runtime-only TEPP credential setting, kept API integration fixtures collision-free, aligned asynchronous Ask tests with semantic retrieval, replaced deprecated FastAPI 422 constants, and moved Starlette integration tests to its supported `httpx2` transport.
+- Restored the runtime-only TEPP credential setting, kept API integration fixtures collision-free, aligned asynchronous Ask tests with semantic retrieval, replaced deprecated FastAPI 422 constants, moved Starlette integration tests to its supported `httpx2` transport, and adopted the SPDX license expression required by current packaging metadata.
diff --git a/pyproject.toml b/pyproject.toml
index 407679fd6..bc85fb6d0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,7 +3,7 @@ name = "lineageweave"
version = "2.18.0"
description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication."
readme = "README.md"
-license = { text = "MIT" }
+license = "MIT"
# Was >=3.10; fast-mlsirm (see [project.optional-dependencies].backend)
# declares >=3.12 itself, so this floor must match or `uv`'s resolver
# reports the whole project unsatisfiable.
From 9ca0582995a2b75f67e4dcbd5f240fa0c537c586 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 07:56:47 +0900
Subject: [PATCH 052/393] fix(dashboard): meet minimum evidence touch target
---
CHANGELOG.md | 2 ++
docs/doctoring/DESIGN_TOKEN_REFERENCES.md | 2 +-
docs/operability/http-concurrency-evidence.md | 11 +++++++++++
docs/product-technical-gap-baseline.md | 10 ++++++++--
frontend/src/App.css | 6 ++++++
frontend/src/styles/tokens.test.ts | 8 ++++++++
6 files changed, 36 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a695667a2..2f6202f99 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,8 @@ All notable changes to this project are documented here. Format follows
### Changed
+- Dashboard evidence-link hit areas now honor the shared minimum control-size token on touch layouts.
+
- Async Ask queue tests now isolate queue settlement from semantic retrieval,
and the development test stack follows Starlette's maintained `httpx2`
`TestClient` contract. FastAPI 422 responses use the RFC 9110 constant, so
diff --git a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
index c22317628..43e8846dc 100644
--- a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
+++ b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md
@@ -10,7 +10,7 @@ Ask evidence dialog, and the Storybook inventory.
|---|---|---|
| W3C Design Tokens Format Module 2025.10 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--size-control-min`, `--radius-chip`, `--radius-control`, `--radius-panel`, and `--font-*`. `CitationChip`, `PopupCloseButton`, `CutoffKnownBody`, and `LineageEntityPicker` read those names through `App.css`. |
| Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. |
-| WCAG 2.2 | Give interactive controls programmatic names and announce an asynchronous evidence failure instead of leaving a perpetual loading state. | Component interaction tests exercise the named controls; `EvidencePanel` exposes its terminal failure with `role="alert"`. This is targeted evidence, not a claim of complete WCAG conformance. |
+| WCAG 2.2 | Give interactive controls programmatic names, meet SC 2.5.8's 24×24 CSS-pixel minimum target, and announce an asynchronous evidence failure instead of leaving a perpetual loading state. | Component interaction tests exercise the named controls; Dashboard evidence links consume `--size-control-min`; `EvidencePanel` exposes its terminal failure with `role="alert"`. This is targeted evidence, not a claim of complete WCAG conformance. |
| WAI-ARIA APG Dialog (Modal) Pattern | A surface marked `aria-modal="true"` must behave modally: focus moves inside, `Tab` and `Shift+Tab` remain inside, and `Escape` closes the layer. | `AskEvidenceLayerPopup` moves initial focus inside the dialog and explicitly cycles forward/backward keyboard focus between its actionable controls; component tests cover both focus-loop directions and Escape. Its evidence lists use dialog-specific accessible labels so assistive technology can distinguish the modal list from the still-rendered inline answer. |
## APA 7th references
diff --git a/docs/operability/http-concurrency-evidence.md b/docs/operability/http-concurrency-evidence.md
index bca6d5b3f..2af736e69 100644
--- a/docs/operability/http-concurrency-evidence.md
+++ b/docs/operability/http-concurrency-evidence.md
@@ -117,3 +117,14 @@ duplicate filter-option query; they do not demonstrate current-head latency,
causality, capacity, or an SLO. ADR 0212 combines the two option projections
into one database query; its physical plan remains to be measured exact-head.
Repeat the synthetic k6 run on an exact-head image before comparing effects.
+
+## Operations Dashboard exact-head observation
+
+On 2026-08-26 KST, candidate `361641ec` ran from a freshly built, isolated
+Compose project with the repository's 27-post synthetic dataset, 4 VUs, and a
+30-second observation window. It completed 974 iterations and 3,898 HTTP
+requests with zero failed requests and 3,896/3,896 successful reader checks.
+HTTP p95 was 194.70 ms; ordinary-reader p95 was 204.06 ms; Ask enqueue was
+104.90 ms. This local synthetic observation is not a capacity guarantee or an
+approved SLO; repeat it on the protected merge SHA and representative declared
+deployment capacity.
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 326f3a39f..18050a478 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -72,6 +72,12 @@ required before protected delivery can be claimed.
Authenticated authorized-corpus acceptance remains separate and may return
only aggregate, non-identifying evidence to this repository.
+The current candidate was also re-rendered from the isolated synthetic stack
+at 1440×1100 and 402×1200 after the touch-target repair. Both viewports had
+document width equal to viewport width, no browser console/page errors, and no
+visible button or link below the shared 24px WCAG 2.2 minimum target token.
+The screenshots remain local `/tmp` audit artifacts and are not committed.
+
At the repaired dashboard head, the `EvidenceReady` and `NarrowViewport`
stories were re-rendered locally with synthetic data at desktop and iPhone
13 viewports. The desktop shows separate Event/post values and evidence
@@ -417,12 +423,12 @@ this file per §3.5 of the prior snapshot).
| Gap | Current evidence | Acceptance requirement |
| --- | --- | --- |
-| Protected release | 3 open PRs at snapshot: #627 and #628 are current-main performance follow-ups, while reopened #579 retains hosted and independent-review gates | Terminal exact-head checks, no unresolved threads, independent exact-head approvals, protected squash-merge SHA |
+| Protected release | 11 open PRs at the 07:26 KST snapshot; the exact-head inventory in section 1 records their current evidence boundaries | Terminal exact-head checks, no unresolved threads, independent exact-head approvals, protected squash-merge SHA |
| Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. Authorized-corpus backfill acceptance remains unavailable | Perform authenticated authorized-corpus acceptance with aggregate evidence and retain fail-closed no-match behavior |
| Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push |
| Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc |
| Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence |
-| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `b045a6e5` completed a synthetic authenticated 4-VU/30-second k6 run with 4,962 requests and zero failures. After schema/lifecycle repairs, `7e63d8c2` replayed through migration 0216, passed the real-PostgreSQL Dashboard contract, and completed 1,382 requests with zero failures (HTTP p95 593.53 ms; reader p95 637.94 ms) while the host remained cold-start loaded. Candidate `1fb65c2e` additionally passed fresh authenticated desktop/mobile browser acceptance without HTTP or console failures. These are local correctness/concurrency observations, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence |
+| Concurrent web responsiveness | ADR 0204 releases pooled transactions during provider work. Candidate `361641ec` completed a freshly built, isolated, authenticated 4-VU/30-second k6 run over 27 synthetic posts: 3,898 requests, 0 failures, HTTP p95 194.70 ms, reader p95 204.06 ms, and Ask enqueue 104.90 ms. Earlier candidate observations remain in the operability record. These are local correctness/concurrency observations, not a product guarantee | Repeat on the protected merge SHA and a representative deployment/corpus with declared CPU, memory, database pool, worker concurrency, and raw output; approve an SLO only from that capacity evidence |
| Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing |
| Semantic source rendering | ADR 0223 and migration 0221 give new paragraph, list, table, MathML formula, and caller-parsed conversation-turn units explicit persisted kinds without rewriting historical rows; image regions remain ordered normalized children under ADR 0091. This branch is candidate evidence, not protected-main delivery | Land the exact-head candidate, then prove an authorized semantic-only query retrieves each persisted unit kind and gather authenticated browser evidence that nesting, continuation alignment, formula units, and image regions retain source order |
| Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence |
diff --git a/frontend/src/App.css b/frontend/src/App.css
index a3a13d531..0bfe4f13c 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -225,6 +225,12 @@
background: var(--color-btn-secondary-hover);
}
+.btn-link {
+ display: inline-flex;
+ align-items: center;
+ min-height: var(--size-control-min);
+}
+
/* Language Switcher */
.language-switcher {
display: inline-flex;
diff --git a/frontend/src/styles/tokens.test.ts b/frontend/src/styles/tokens.test.ts
index 435c973ad..0f08f1c68 100644
--- a/frontend/src/styles/tokens.test.ts
+++ b/frontend/src/styles/tokens.test.ts
@@ -174,6 +174,14 @@ describe("design tokens", () => {
expect(citationChipBlock).toContain("display: inline-flex");
expect(citationChipBlock).toContain("align-items: center");
});
+
+ it("gives Dashboard evidence links the shared minimum touch target", () => {
+ const rule = appCss.match(/\.btn-link\s*\{[^}]*\}/)?.[0] ?? "";
+ expect(rule, ".btn-link rule not found in App.css").not.toBe("");
+ expect(rule).toContain("min-height: var(--size-control-min)");
+ expect(rule).toContain("display: inline-flex");
+ expect(rule).toContain("align-items: center");
+ });
});
describe("secondary disclosure toggle touch targets", () => {
From bc4a251d37d4732c8f8a125cbc032f7a817968f6 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 07:57:48 +0900
Subject: [PATCH 053/393] docs(gaps): record temporal producer prerequisites
---
docs/product-technical-gap-baseline.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 18050a478..10889f57d 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -17,7 +17,7 @@
| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending |
| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending |
| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet |
-| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open; #248 was closed because diagonal Laplace moments cannot represent the joint posterior, while stacked #251/#252 add fail-closed and joint-precision prerequisites only. fast-mlsirm still has no accepted influence result. Runtime therefore remains honestly unavailable with no local Python or fallback score. |
+| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open at `063f10f3`; stacked #251–#254 provide fail-closed input validation, full joint precision, deterministic joint plausible-value draws, and the canonical research register, while complete provenance assembly remains gated. fast-mlsirm PR #1418 validates the Rust consumer envelope but intentionally returns `EstimatorUnavailable` until the scientific estimator lands. Runtime therefore remains honestly unavailable with no local Python or fallback score. |
### Technical contract and flow
From 88c0632fb46a11579b47441f12e77e5527499a9d Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 08:09:32 +0900
Subject: [PATCH 054/393] fix(dashboard): scope external headline metrics
---
backend/app/operations_dashboard.py | 26 +++++++++++++++++++-------
tests/test_operations_dashboard.py | 9 +++++----
2 files changed, 24 insertions(+), 11 deletions(-)
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index 2f66db4a5..07fc80349 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -176,33 +176,45 @@ async def fetch_operations_dashboard(
join source_post evidence_post
on evidence_post.post_id = classification.evidence_post_id
where {visible_evidence}
+ ), scoped_post as (
+ select visible_post.post_id
+ from visible_post
+ where $5::boolean is false
+ or exists (
+ select 1
+ from classified
+ where classified.post_id = visible_post.post_id
+ and classified.case_kind_code = 'external_information'
+ )
)
- select (select count(*) from visible_post) as total_post_count,
+ select (select count(*) from scoped_post) as total_post_count,
(select count(*)
from post_summary_event summary_event
where exists (
select 1 from classified
where classified.post_id = summary_event.post_id
+ and ($5::boolean is false
+ or classified.case_kind_code = 'external_information')
)) as total_event_count,
(select count(distinct post_id) from classified
where case_kind_code = 'external_information') as external_post_count,
- (select count(*) from visible_post
+ (select count(*) from scoped_post
where not exists (
select 1 from operations_case_analysis analysis
- where analysis.post_id = visible_post.post_id
+ where analysis.post_id = scoped_post.post_id
) and not exists (
select 1 from post_content_ingestion_job job
- where job.post_id = visible_post.post_id
+ where job.post_id = scoped_post.post_id
and job.status_code = 'post_content_ingestion_failed'
)) as pending_analysis_count,
- (select count(*) from visible_post
+ (select count(*) from scoped_post
where exists (
select 1 from post_content_ingestion_job job
- where job.post_id = visible_post.post_id
+ where job.post_id = scoped_post.post_id
and job.status_code = 'post_content_ingestion_failed'
)) as failed_analysis_count
""",
- *args[:4],
+ *args,
)
case_rows = await conn.fetch(
f"""
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index cb0dd6f55..5ec0a0543 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -397,16 +397,17 @@ async def test_topic_readiness_uses_projection_temporal_windows() -> None:
assert "visible_post.occurred_at < activity.valid_to" in readiness_query
@pytest.mark.anyio
-async def test_external_scope_filters_cases_without_changing_total_population() -> None:
- """External-only cases retain whole authorized-corpus metric denominators."""
+async def test_external_scope_filters_cases_and_headline_population() -> None:
+ """External-only metrics and cases share one authorized population."""
conn = _Connection()
await fetch_operations_dashboard(
conn, ["corp"], ["pu"], date(2026, 8, 1), date(2026, 8, 31), external_only=True
)
assert conn.queries
metrics_query, metrics_args = conn.queries[0]
- assert "$5::boolean" not in metrics_query
- assert len(metrics_args) == 4
+ assert "scoped_post" in metrics_query
+ assert "$5::boolean" in metrics_query
+ assert metrics_args[-1] is True
for query, args in conn.queries[1:]:
assert "$5::boolean" in query
assert args[-1] is True
From ab4359039263cc73ef34181c8dcf17e86f4ee584 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 08:18:35 +0900
Subject: [PATCH 055/393] fix(dashboard): retain external coverage denominator
---
CHANGELOG.md | 1 +
backend/app/operations_dashboard.py | 2 +-
backend/tests/test_operations_dashboard_postgres.py | 2 ++
tests/test_operations_dashboard.py | 6 ++++--
4 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f6202f99..823515c43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ All notable changes to this project are documented here. Format follows
### Changed
- Dashboard evidence-link hit areas now honor the shared minimum control-size token on touch layouts.
+- The external-information GNB keeps all authorized in-period posts as its coverage denominator while filtering the displayed case rows.
- Async Ask queue tests now isolate queue settlement from semantic retrieval,
and the development test stack follows Starlette's maintained `httpx2`
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index 07fc80349..ae8f3eb23 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -187,7 +187,7 @@ async def fetch_operations_dashboard(
and classified.case_kind_code = 'external_information'
)
)
- select (select count(*) from scoped_post) as total_post_count,
+ select (select count(*) from visible_post) as total_post_count,
(select count(*)
from post_summary_event summary_event
where exists (
diff --git a/backend/tests/test_operations_dashboard_postgres.py b/backend/tests/test_operations_dashboard_postgres.py
index ef529867f..fad533606 100644
--- a/backend/tests/test_operations_dashboard_postgres.py
+++ b/backend/tests/test_operations_dashboard_postgres.py
@@ -38,10 +38,12 @@ async def test_operations_dashboard_sql_binds_against_postgres() -> None:
) is None:
pytest.skip(f"requires the migration that creates {table_name}")
result = await fetch_operations_dashboard(connection, [])
+ external_result = await fetch_operations_dashboard(connection, [], external_only=True)
finally:
await connection.close()
assert result["total_post_count"] >= 0
+ assert external_result["total_post_count"] == result["total_post_count"]
assert result["topic_context"]["status_code"] in {"accepted", "unavailable"}
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index 5ec0a0543..4a5b9c0bf 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -397,8 +397,8 @@ async def test_topic_readiness_uses_projection_temporal_windows() -> None:
assert "visible_post.occurred_at < activity.valid_to" in readiness_query
@pytest.mark.anyio
-async def test_external_scope_filters_cases_and_headline_population() -> None:
- """External-only metrics and cases share one authorized population."""
+async def test_external_scope_filters_cases_without_shrinking_coverage_denominator() -> None:
+ """External-only cases retain all visible posts as the percentage denominator."""
conn = _Connection()
await fetch_operations_dashboard(
conn, ["corp"], ["pu"], date(2026, 8, 1), date(2026, 8, 31), external_only=True
@@ -406,6 +406,8 @@ async def test_external_scope_filters_cases_and_headline_population() -> None:
assert conn.queries
metrics_query, metrics_args = conn.queries[0]
assert "scoped_post" in metrics_query
+ assert "count(*) from visible_post) as total_post_count" in metrics_query
+ assert "count(*) from scoped_post) as total_post_count" not in metrics_query
assert "$5::boolean" in metrics_query
assert metrics_args[-1] is True
for query, args in conn.queries[1:]:
From 65bb0ac6bb1ddc198a89cf15dcc374dd66651e95 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 08:25:14 +0900
Subject: [PATCH 056/393] docs(changelog): keep one unreleased changed section
---
CHANGELOG.md | 2 --
1 file changed, 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 823515c43..d145e6986 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -119,8 +119,6 @@ All notable changes to this project are documented here. Format follows
authorized leftover store as the period-report list; they do not
invent a leftover score.
-### Changed
-
- ADRs 0011 and 0065 now include APA 7th References for the dated W3C
PROV-O and PROV-DM Recommendations (30 April 2013). Decisions are
unchanged.
From 4ce167d13a0efc99b578f012b39739d9d419f5f5 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 16:31:47 -0700
Subject: [PATCH 057/393] refactor(measurement): consume Rust residual
interaction map (#670)
Co-authored-by: Codex
---
AGENTS.md | 5 +-
CHANGELOG.md | 6 +
docs/adr/0048-persist-lsirm-leftover-pairs.md | 14 +-
docs/adr/0148-leftover-map-axis-share.md | 10 +-
...-externalize-local-mathematical-compute.md | 11 +-
...hon-mathematical-compute-boundary-audit.md | 8 +-
docs/product-technical-gap-baseline.md | 1 +
lineageweave/leftover_pairs.py | 426 +++---------
lineageweave/period_report.py | 35 +-
pyproject.toml | 2 +-
tests/test_leftover_pairs.py | 650 ++----------------
uv.lock | 6 +-
12 files changed, 198 insertions(+), 976 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index c927f9e61..0b9e33828 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -271,8 +271,9 @@ stops startup instead of leaving a healthy-looking partial schema, and
application code must not compensate for a missing table.
Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0119 / 0158 / 0162 /
-0163 / 0164 / 0182 / 0201) are computed in `lineageweave/leftover_pairs.py` from the
-residual after a real GRM/GPCM score, never invented. Distances are
+0163 / 0164 / 0182 / 0201 / 0208) consume fast-mlsirm's Rust-owned residual
+interaction map after a real GRM/GPCM score, never invented. LineageWeave only
+attaches product identifiers and selects returned cells. Distances are
Euclidean on the two-dimensional Gabriel leftover map; missing cells stay
out of the factorization. Closest and farthest post–criterion pairs
persist to `report_leftover_pair` with signed residual `R`, observed
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d145e6986..fbef5686e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@ All notable changes to this project are documented here. Format follows
### Changed
+- Period-report leftover maps now consume fast-mlsirm's protected Rust
+ residual-interaction and expected-response contracts. Local Python Gabriel
+ SVD, distance, reconstruction, share, expectation, and duplicate likelihood
+ arithmetic were removed; owner failures remain unavailable rather than
+ triggering a local substitute.
+
- Dashboard evidence-link hit areas now honor the shared minimum control-size token on touch layouts.
- The external-information GNB keeps all authorized in-period posts as its coverage denominator while filtering the displayed case rows.
diff --git a/docs/adr/0048-persist-lsirm-leftover-pairs.md b/docs/adr/0048-persist-lsirm-leftover-pairs.md
index 613545db4..62d2a5e15 100644
--- a/docs/adr/0048-persist-lsirm-leftover-pairs.md
+++ b/docs/adr/0048-persist-lsirm-leftover-pairs.md
@@ -6,7 +6,8 @@
[ADR 0163](0163-leftover-observed-expected.md) (observed Y and expected E);
[ADR 0164](0164-leftover-map-rank.md) (full map rank);
[ADR 0182](0182-leftover-map-unexplained.md) (unexplained leftover U);
-[ADR 0185](0185-leftover-map-cross-share.md) (leftover-map cross share)
+[ADR 0185](0185-leftover-map-cross-share.md) (leftover-map cross share); and
+[ADR 0208](0208-externalize-local-mathematical-compute.md) (Rust owner boundary)
## Context
@@ -17,15 +18,15 @@ leftover interaction `−γ‖ξ_p − ζ_i‖` on the person–item map. Closes
pairs are the smallest Euclidean leftover-map distances; farthest
pairs are the largest.
-`fast-mlsirm` implements the leftover term inside MLSRM fitting but
-exposes no leftover-pair API. LineageWeave must not fork LSIRM or
-invent a second IRT fit. Buyers still need a durable, clickable
+`fast-mlsirm` now exposes the Rust-owned residual interaction-map contract.
+LineageWeave must not fork its factorization or invent a second IRT fit.
+Buyers still need a durable, clickable
answer to “which post–criterion pair is unexpectedly aligned, and
which pair is unexpectedly opposed?”
## Decision
-After a real GRM/GPCM score, compute the residual matrix
+After a real GRM/GPCM score, consume fast-mlsirm's Rust-computed residual matrix
`R = Y − E[Y|θ, item]` from the already-fitted category
probabilities. A Gabriel (1971) biplot of the **complete-case**
submatrix of `R` supplies person positions `ξ` and item positions
@@ -34,7 +35,8 @@ they are never filled with zero. Persist exactly one `closest`
and one `farthest` observed cell per period report in
`report_leftover_pair` (3NF, two-or-more-word `snake_case`).
-tests do not import `period_report` or `fast_mlsirm`. Distances are
+LineageWeave attaches product identifiers and selects the closest/farthest
+returned cells; it does not reproduce the numerical formulas. Distances are
Euclidean on the two leftover-map axes (ADR 0119). Each leftover row
also names observed `Y` and expected `E[Y|θ, item]` so residual
reconciles to `Y − E` (ADR 0163), and names the full singular-value
diff --git a/docs/adr/0148-leftover-map-axis-share.md b/docs/adr/0148-leftover-map-axis-share.md
index 2540ddf91..f99f1420f 100644
--- a/docs/adr/0148-leftover-map-axis-share.md
+++ b/docs/adr/0148-leftover-map-axis-share.md
@@ -16,9 +16,9 @@ share is a report-level property of the residual SVD, not a
post-identifying leftover score and not a second theta. Denormalizing
it onto each leftover pair would violate 3NF.
-`fast-mlsirm` still exposes no leftover-pair or leftover-map API.
-LineageWeave must not fork LSIRM or invent leftover numbers when the
-residual is rank-0.
+`fast-mlsirm` exposes the Rust-owned residual interaction-map API.
+LineageWeave must consume its singular values and shares without reproducing
+the factorization or inventing leftover numbers when the residual is rank-0.
## Decision
@@ -36,8 +36,8 @@ Cascade the rows with `report_period_score`. Axes are aggregate and
non-identifying: ABAC that hides leftover pairs does not hide axis
share. Do not store a second theta. Do not invent leftover numbers.
-The biplot lives in `lineageweave/leftover_pairs.py` so leftover tests
-do not import `period_report` or `fast_mlsirm`.
+The biplot lives in fast-mlsirm's Rust core. `leftover_pairs.py` only projects
+the returned array indices onto authorized post and criterion identifiers.
## Consequences
diff --git a/docs/adr/0208-externalize-local-mathematical-compute.md b/docs/adr/0208-externalize-local-mathematical-compute.md
index 42a5a0591..31d5b13ac 100644
--- a/docs/adr/0208-externalize-local-mathematical-compute.md
+++ b/docs/adr/0208-externalize-local-mathematical-compute.md
@@ -54,7 +54,7 @@ different responsibility.
Missing, malformed, non-converged, mixed-snapshot, or unsupported results
fail closed. It never repairs, normalizes, estimates, or substitutes a
numerical result.
-5. **No big-bang rewrite.** Existing local computation is frozen as named
+5. **No big-bang rewrite.** Remaining local computation is frozen as named
migration debt in
`docs/doctoring/python-mathematical-compute-boundary-audit.md`. Each owner
contract lands and proves recovery/equivalence before the corresponding
@@ -69,6 +69,14 @@ different responsibility.
Operational bounds may remain only as disclosed resource limits and may
not determine a scientific score or ground truth.
+## Implemented migration slices
+
+- The residual interaction map consumes fast-mlsirm's protected-main
+ `residual_interaction_map` and `polytomous_expected_response` contracts.
+ Gabriel SVD, axis inertia, distance, reconstruction, unexplained residual,
+ cross share, and coverage arithmetic were deleted from LineageWeave Python.
+ Product-side identifier attachment and closest/farthest selection remain.
+
## Stacked delivery order
1. Owner PRs publish versioned request/result schemas, model identity,
@@ -113,4 +121,3 @@ https://doi.org/10.1007/s11336-021-09762-5
Roberts, M. E., Stewart, B. M., & Tingley, D. (2019). stm: An R package for
structural topic models. *Journal of Statistical Software, 91*(2), 1–40.
https://doi.org/10.18637/jss.v091.i02
-
diff --git a/docs/doctoring/python-mathematical-compute-boundary-audit.md b/docs/doctoring/python-mathematical-compute-boundary-audit.md
index f1dbeee83..74e129eec 100644
--- a/docs/doctoring/python-mathematical-compute-boundary-audit.md
+++ b/docs/doctoring/python-mathematical-compute-boundary-audit.md
@@ -3,8 +3,8 @@
**Exact-head audit date:** 2026-08-25
**Normative decision:** [ADR 0208](../adr/0208-externalize-local-mathematical-compute.md)
-This inventory names migration debt; it is not evidence that the current
-Python paths satisfy the Rust/GPU requirement.
+This inventory names remaining migration debt and completed owner slices; it
+does not relabel still-local Python paths as Rust/GPU compliant.
## Product-boundary sources read
@@ -23,8 +23,8 @@ Python paths satisfy the Rust/GPU requirement.
| Current LineageWeave path | Local computation | Owner | Consumer replacement | Principal callers / tests |
|---|---|---|---|---|
| `lineageweave/channel_weight_estimation.py` | dichotomization, synthetic simulation, MLS2PLM input construction, expected item information and normalization | fast-mlsirm, conditional on TEPP anchor | versioned anchored-weight artifact; strict digest/convergence validation | estimation scripts, seed/server/rebuild paths; `tests/test_channel_weight_estimation.py`, estimator-script tests |
-| `lineageweave/period_report.py` | response matrix, GRM/GPCM fit/FIPC/EAP, likelihood, category expectation, information ordering | fast-mlsirm | period-measurement artifact with item bank, scores, uncertainty, diagnostics | report ingestion and demo seed; period-report and report API tests |
-| `lineageweave/leftover_pairs.py` | residual matrix, complete-case selection, SVD/Gabriel coordinates, distances, reconstruction, axis shares | fast-mlsirm | residual-interaction artifact with observed/expected identity and coverage | `period_report.py`, report ingestion/seed; `tests/test_leftover_pairs.py`, report tests |
+| `lineageweave/period_report.py` | response matrix and owner-call orchestration remain; local category expectation and duplicate likelihood arithmetic removed | fast-mlsirm | `polytomous_expected_response`; diagnostics-owned held-out log likelihood; full period artifact remains debt | report ingestion and demo seed; period-report and report API tests |
+| `lineageweave/leftover_pairs.py` | **migrated:** identifier projection and closest/farthest selection only | fast-mlsirm | protected-main Rust `residual_interaction_map` with residual, coverage, SVD/Gabriel coordinates, distances, reconstruction and shares | `period_report.py`, report ingestion/seed; owner contract and consumer projection tests |
| `lineageweave/embedding_client.py` and `backend/app/post_chat_ingestion.py` | cosine similarity, vector norms, maximum semantic score | RankWeave retrieval-score contract | ranked evidence envelope over ABAC-visible semantic units | reconstruction text channel and Global Ask retrieval; embedding/post-chat tests |
| `lineageweave/knowledge_graph.py` | random walk with restart, convergence delta, adaptive relevance cutoff | RankWeave graph-ranking contract | ranked-node artifact with contribution and convergence evidence | related-person/entity API paths; knowledge-graph tests |
| `lineageweave/reconstruct.py` | channel-weight renormalization, candidate-score fusion and minimum-score decision | RankWeave fusion; TEPP supplies independent lineage criterion | accepted edge-ranking artifact; LineageWeave persists selected edge and channel provenance | lineage rebuild/start/seed/server; reconstruct, persistence, API tests |
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 10889f57d..69074b9d0 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -438,6 +438,7 @@ this file per §3.5 of the prior snapshot).
| SKOS organization aliases | Catalog binding and chip caption live on #480 / #482 | One catalog row per corroborated org; companion caption is hint-only until bound |
| Event Lineage evidence | Channel evidence and Allen relations live on #387 / #484 | Persist channel scores, explain them in the popup, never invent a fused score |
| Scientific measurement | Durable accepted TEPP receipts and LineageWeave #614's exact accepted snapshot/cutoff/run/pair-count consumer are protected; TEPP #237 remains open, so no registered producer artifact exists yet. #387 removes inferred/default persistence weights, but several older reconstruction tests still pass hand-authored numeric dictionaries that are not estimator evidence | Land TEPP #237 through its protected gate, then replace remaining reconstruction-test constants with provenance-bearing fast-mlsirm estimates over synthetic fixtures. Retain true-parameter RMSE recovery as the acceptance bar |
+| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Land this stacked consumer through protected gates, then migrate each remaining construct to its owning repository contract and require an empty transition inventory before claiming the boundary complete |
| Asynchronous authorization | Protected `main` rebuilds Global Ask worker scope after the bearer token leaves the request; #468 now persists exact Keyverse organization/process-unit scope in 3NF child tables and intersects it with current affiliations | Land #468 through the protected gate; prove a second affiliation and a revoked process unit cannot widen delayed-job evidence |
| Planned-facility intent | Planned-facility relationship intent remains only on closed, unmerged #490; earlier stack-only merges were not protected delivery | Recreate the evidence-backed slice on a current base and land through protected `main` before a release claim |
| Accessibility and responsive UX | #602 delivered base post-detail modal semantics; #605 adds selected-post refocus, collapsed/hidden/inert/CSS-invisible focus exclusion across both modal types, readable evidence separators, focused tests, and desktop/mobile Storybook screenshots | Land #605 through the protected gate, then complete screen-reader and authenticated Playwright acceptance on the exact release head |
diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py
index 070416fe2..87a563bc1 100644
--- a/lineageweave/leftover_pairs.py
+++ b/lineageweave/leftover_pairs.py
@@ -1,34 +1,8 @@
-"""Jeon leftover post–criterion pairs after a main-effect IRT.
+"""Project Rust-owned residual interaction maps into LineageWeave rows.
-Implements ADR 0048 as amended by ADR 0119, ADR 0163, ADR 0164, ADR 0182,
-and ADR 0185.
-
-Does not import ``fast_mlsirm`` or ``period_report``. A Gabriel biplot
-of the residual ``R = Y − E[Y|θ, item]`` supplies person and item
-positions. Missing response cells are excluded from the factorization;
-they are never treated as zero residuals. Each pair names observed
-``Y`` and expected ``E`` so residual always reconciles to ``Y − E``.
-Pair distances are Euclidean on the two leftover-map axes (Jeon et al.,
-2021); unused axes pad with zero rather than inventing a second
-component, and hidden SVD axes after the second are dropped. Each pair
-also names the full leftover-map rank so a rank-0 collapse is not read
-as leftover structure. Axis share is the Gabriel inertia of the first
-two leftover-map axes (ADR 0148). Complete-case coverage (ADR 0168)
-names how many scored posts entered that rectangle; without a
-complete-case rectangle there is no leftover pair to name, and the
-report carries coverage counts instead of a center-distance stand-in
-pair. Each pair also names unexplained leftover ``U = R − R̂`` after
-two-axis Gabriel reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` so the
-leftover cell the map does not reconstruct is not confused with
-leftover residual ``R`` or leftover-map distance ``d``. Each pair
-further names leftover-map cross share ``x = 2 R̂ U / R²`` of the raw
-residual after that same truncated two-axis reconstruction,
-so the identity remainder left by the truncation is not confused with
-leftover residual ``R``, leftover-map distance ``d``, or unexplained
-leftover ``U``. Explained leftover share ``e = R̂² / R²`` and
-unexplained leftover share ``s = U² / R²`` are not persisted. Signed
-reconstruction ``R̂`` is persisted so ``U + R̂ = R`` stays auditable. ``x``
-may be negative when reconstruction and unexplained leftover have opposite signs.
+The numerical factorization belongs to :mod:`fast_mlsirm`. This module owns
+only product identifiers, closest/farthest selection, and persistence-shaped
+records (ADR 0208).
"""
from __future__ import annotations
@@ -36,17 +10,16 @@
from dataclasses import dataclass
import numpy as np
+from fast_mlsirm import residual_interaction_map
PAIR_KIND_CLOSEST = "closest"
PAIR_KIND_FARTHEST = "farthest"
-_LEFTOVER_SINGULAR_FLOOR = 1e-12
-_RESIDUAL_RECONCILE_TOLERANCE = 1e-6
_LEFTOVER_MAP_AXES = 2
@dataclass(frozen=True)
class LeftoverPair:
- """One post–criterion pair on the leftover interaction map."""
+ """One post-criterion pair on the leftover interaction map."""
pair_kind: str
post_id: str
@@ -88,25 +61,8 @@ def leftover_pairs_from_residual(
matrix: np.ndarray,
expected: np.ndarray,
) -> tuple[LeftoverPair, ...]:
- """Closest and farthest leftover-map pairs from residual SVD biplot.
+ """Return closest and farthest rows from the Rust-owned interaction map."""
- Jeon et al. (2021) leftover interaction is ``−γ‖ξ_p − ζ_i‖``. This
- estimator places persons and items from the residual after IRT main
- effects (Gabriel, 1971). Only observed cells become pairs. Distances
- use the two leftover-map axes; a rank-0 residual still emits a
- stable closest/farthest pair so seed is not empty and does not
- invent a leftover score. Stored residual equals observed ``Y`` minus
- expected ``E[Y|θ, item]``. Stored leftover-map rank is the number
- of Gabriel singular values above the floor. When Gabriel coordinates
- exist, unexplained leftover ``U = R − R̂`` names the leftover cell
- the two-axis map does not reconstruct, and leftover-map cross share
- ``x = 2 R̂ U / R²`` names the identity remainder of raw residual
- ``R`` after two-axis reconstruction ``R̂ = ξ_{1:2} · ζ_{1:2}`` and
- unexplained leftover ``U = R − R̂``. Signed ``R̂`` is persisted with
- ``U`` so their raw-residual identity stays auditable. Without a complete-case map there is no pair
- to name (ADR 0168); the caller reads coverage counts instead of a
- center-distance stand-in pair.
- """
pairs, _axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected)
return pairs
@@ -117,331 +73,109 @@ def leftover_map_from_residual(
matrix: np.ndarray,
expected: np.ndarray,
) -> tuple[tuple[LeftoverPair, ...], tuple[LeftoverMapAxis, ...]]:
- """Leftover pairs plus the first two Gabriel leftover-map axis shares.
-
- Axis share is ``σ_k² / Σ_j σ_j²`` for leftover-map axes 1 and 2.
- Rank-0 residuals emit two zero-share axes so seed can name leftover-map
- structure without inventing a leftover score.
- """
- if matrix.shape != (len(post_ids), len(item_codes)):
- raise ValueError(
- f"matrix shape {matrix.shape} does not match {len(post_ids)} posts × {len(item_codes)} items"
+ """Project a Rust-owned two-axis Gabriel map into report records."""
+
+ _validate_shapes(post_ids, item_codes, matrix, expected)
+ result = residual_interaction_map(matrix, expected, axis_count=_LEFTOVER_MAP_AXES)
+ axes = tuple(
+ LeftoverMapAxis(
+ axis_index=index + 1,
+ leftover_singular_value=(
+ float(result.singular_values[index])
+ if index < len(result.singular_values)
+ else 0.0
+ ),
+ leftover_share=float(result.axis_shares[index]),
)
- if expected.shape != matrix.shape:
- raise ValueError(f"expected shape {expected.shape} does not match matrix {matrix.shape}")
-
- residual = matrix.astype(np.float64) - expected.astype(np.float64)
- observed_mask = (~np.isnan(matrix)) & np.isfinite(residual) & np.isfinite(expected)
- observed: list[tuple[int, int]] = [
- (person, item)
- for person in range(matrix.shape[0])
- for item in range(matrix.shape[1])
- if observed_mask[person, item]
- ]
- if not observed:
+ for index in range(_LEFTOVER_MAP_AXES)
+ )
+ if not len(result.person_indices) or not len(result.item_indices):
return (), ()
- keep_person, keep_item = _complete_case_masks(observed_mask)
- person_index = np.flatnonzero(keep_person)
- item_index = np.flatnonzero(keep_item)
- if person_index.size > 0 and item_index.size > 0:
- center = float(np.mean(residual[np.ix_(person_index, item_index)]))
- else:
- center = float(np.mean([residual[person, item] for person, item in observed]))
- person_pos, item_pos, singular = _complete_case_positions(
- residual, center, keep_person, keep_item
- )
- axes = leftover_map_axes_from_singular(singular)
- leftover_map_rank = int(singular.size)
- candidates: list[
- tuple[
- float, str, str, float, float, float,
- float | None, float | None, float | None,
- ]
- ] = []
- if person_pos is not None and item_pos is not None:
- person_index = np.flatnonzero(keep_person)
- item_index = np.flatnonzero(keep_item)
- person_xy = _pad_map_axes(person_pos)
- item_xy = _pad_map_axes(item_pos)
- local_person = {int(person): local for local, person in enumerate(person_index)}
- local_item = {int(item): local for local, item in enumerate(item_index)}
- for person, item in observed:
- if person not in local_person or item not in local_item:
- continue
- distance = float(
- np.linalg.norm(person_xy[local_person[person]] - item_xy[local_item[item]])
- )
- if not np.isfinite(distance):
- continue
- reconstruction = float(
- np.dot(person_xy[local_person[person]], item_xy[local_item[item]])
- )
- residual_cell = float(residual[person, item])
- unexplained = _unexplained_leftover(residual_cell, reconstruction)
- share = _leftover_map_cross_share(residual_cell, reconstruction)
+ rank = int(len(result.singular_values))
+ candidates: list[tuple[float, str, str, float, float, float, float, float | None, float]] = []
+ for local_person, person in enumerate(result.person_indices):
+ for local_item, item in enumerate(result.item_indices):
+ observed = float(matrix[int(person), int(item)])
+ model_expected = float(expected[int(person), int(item)])
+ residual = float(result.residual[local_person, local_item])
+ cross_share = float(result.cross_share[local_person, local_item])
candidates.append(
- _candidate_row(
- post_ids,
- item_codes,
- matrix,
- expected,
+ (
+ float(result.distance[local_person, local_item]),
+ post_ids[int(person)],
+ item_codes[int(item)],
residual,
- person,
- item,
- distance,
- unexplained,
- share,
- reconstruction if np.isfinite(reconstruction) else None,
+ observed,
+ model_expected,
+ float(result.unexplained[local_person, local_item]),
+ cross_share if np.isfinite(cross_share) else None,
+ float(result.reconstruction[local_person, local_item]),
)
)
- if not candidates:
- # ADR 0168: without a complete-case Gabriel map there is no
- # leftover pair to name. The report carries coverage counts
- # instead of a center-distance stand-in pair.
- return (), ()
closest = min(candidates, key=lambda row: (row[0], row[1], row[2]))
farthest = max(candidates, key=lambda row: (row[0], row[1], row[2]))
- pairs = (
- _pair_from_candidate(PAIR_KIND_CLOSEST, closest, leftover_map_rank),
- _pair_from_candidate(PAIR_KIND_FARTHEST, farthest, leftover_map_rank),
+ return (
+ (_pair(PAIR_KIND_CLOSEST, closest, rank), _pair(PAIR_KIND_FARTHEST, farthest, rank)),
+ axes,
)
- return pairs, axes
-
-
-def _unexplained_leftover(residual: float, reconstruction: float) -> float | None:
- """Return ``U = R − R̂`` when both terms are finite; otherwise omit."""
- if not np.isfinite(reconstruction):
- return None
- unexplained = residual - reconstruction
- if not np.isfinite(unexplained):
- return None
- return float(unexplained)
-
-
-def _leftover_map_cross_share(residual: float, reconstruction: float) -> float | None:
- """Return ``x = 2 R̂ U / R²`` when both terms are finite; otherwise omit.
- Unexplained leftover ``U = R − R̂`` is computed internally.
- Truncated two-axis reconstruction of a higher-rank cell keeps a
- cross term ``2 R̂ U``, so per-cell ``e + s ≠ 1``. The identity
- remainder ``x`` names that cross term as a share of raw residual.
- ``x`` may be negative when reconstruction and unexplained
- leftover have opposite signs; a negative finite share is stored,
- not omitted.
- """
- if not np.isfinite(residual) or not np.isfinite(reconstruction):
- return None
- unexplained = float(residual - reconstruction)
- # Threshold on absolute magnitudes, not squares: squaring first makes the
- # effective floor sqrt(1e-12) = 1e-6 and collapses small-but-finite cells
- # (e.g. R = 1e-7 with a valid cross term) to an omitted badge.
- if abs(residual) > _LEFTOVER_SINGULAR_FLOOR:
- share = float(2.0 * reconstruction * unexplained / (residual * residual))
- return share if np.isfinite(share) else None
- if abs(reconstruction) <= _LEFTOVER_SINGULAR_FLOOR and abs(unexplained) <= _LEFTOVER_SINGULAR_FLOOR:
- return 0.0
- return None
-
-def _candidate_row(
+def leftover_map_coverage_from_residual(
post_ids: list[str],
item_codes: tuple[str, ...],
matrix: np.ndarray,
expected: np.ndarray,
- residual: np.ndarray,
- person: int,
- item: int,
- distance: float,
- leftover_map_unexplained: float | None,
- leftover_map_cross_share: float | None,
- leftover_map_reconstruction: float | None,
-) -> tuple[
- float, str, str, float, float, float,
- float | None, float | None, float | None,
-]:
- """One observed cell: distance, ids, residual, Y, E, U, cross share, R̂."""
- leftover_residual = float(residual[person, item])
- observed_response = float(matrix[person, item])
- expected_response = float(expected[person, item])
- if abs(leftover_residual - (observed_response - expected_response)) >= _RESIDUAL_RECONCILE_TOLERANCE:
- raise ValueError("leftover residual must equal observed Y minus expected E")
- return (
- max(distance, 0.0),
- post_ids[person],
- item_codes[item],
- leftover_residual,
- observed_response,
- expected_response,
- leftover_map_unexplained,
- leftover_map_cross_share,
- leftover_map_reconstruction,
- )
-
+) -> LeftoverMapCoverage:
+ """Project Rust-owned complete-case coverage into one report record."""
-def _pair_from_candidate(
- pair_kind: str,
- row: tuple[
- float, str, str, float, float, float,
- float | None, float | None, float | None,
- ],
- leftover_map_rank: int,
-) -> LeftoverPair:
- """Build a leftover pair from a candidate row."""
- if leftover_map_rank < 0:
- raise ValueError("leftover map rank must be a non-negative integer")
- return LeftoverPair(
- pair_kind=pair_kind,
- post_id=row[1],
- criterion_code=row[2],
- leftover_distance=row[0],
- leftover_residual=row[3],
- observed_response=row[4],
- expected_response=row[5],
- leftover_map_rank=leftover_map_rank,
- leftover_map_unexplained=row[6],
- leftover_map_cross_share=row[7],
- leftover_map_reconstruction=row[8],
+ _validate_shapes(post_ids, item_codes, matrix, expected)
+ result = residual_interaction_map(matrix, expected, axis_count=_LEFTOVER_MAP_AXES)
+ map_posts = int(len(result.person_indices))
+ map_items = int(len(result.item_indices))
+ return LeftoverMapCoverage(
+ map_post_count=map_posts,
+ scored_post_count=int(result.scored_person_count),
+ map_item_count=map_items,
+ scored_item_count=int(result.scored_item_count),
+ incomplete_post_count=int(result.scored_person_count) - map_posts,
+ incomplete_item_count=int(result.scored_item_count) - map_items,
)
-def leftover_map_axes_from_singular(singular: np.ndarray) -> tuple[LeftoverMapAxis, ...]:
- """Gabriel axis inertia for leftover-map axes 1 and 2.
-
- Share is ``σ_k² / Σ_j σ_j²``. Values at or below the leftover
- singular floor do not enter the denominator. Rank-0 residuals emit
- two zero-share axes.
- """
- values = np.asarray(singular, dtype=np.float64).reshape(-1)
- kept = values[np.isfinite(values) & (values > _LEFTOVER_SINGULAR_FLOOR)]
- total = float(np.sum(kept * kept)) if kept.size else 0.0
- axes: list[LeftoverMapAxis] = []
- for index in (1, 2):
- value = float(kept[index - 1]) if kept.size >= index else 0.0
- if not np.isfinite(value) or value < 0.0:
- value = 0.0
- share = (value * value / total) if total > 0.0 else 0.0
- axes.append(
- LeftoverMapAxis(
- axis_index=index,
- leftover_singular_value=value,
- leftover_share=max(share, 0.0),
- )
- )
- return tuple(axes)
-
-
-def leftover_map_coverage_from_residual(
+def _validate_shapes(
post_ids: list[str],
item_codes: tuple[str, ...],
matrix: np.ndarray,
expected: np.ndarray,
-) -> LeftoverMapCoverage:
- """Name how many scored posts entered the complete-case leftover map.
+) -> None:
+ """Reject identifier and matrix shapes that cannot be projected safely."""
- Gabriel (1971) factorizes the complete-case residual rectangle.
- Missing cells stay out of that rectangle; they are never filled
- with zero. ``map_post_count`` is the number of posts that entered
- the factorization. ``scored_post_count`` is posts with at least
- one observed cell. Incomplete rows are excluded, never zeroed.
- """
if matrix.shape != (len(post_ids), len(item_codes)):
raise ValueError(
- f"matrix shape {matrix.shape} does not match {len(post_ids)} posts × {len(item_codes)} items"
+ f"matrix shape {matrix.shape} does not match {len(post_ids)} posts x {len(item_codes)} items"
)
if expected.shape != matrix.shape:
raise ValueError(f"expected shape {expected.shape} does not match matrix {matrix.shape}")
- residual = matrix.astype(np.float64) - expected.astype(np.float64)
- # Identical mask to leftover_map_from_residual's: a cell the pair map
- # scores must be exactly a cell coverage counts, so map_post_count and
- # the caption can never drift apart if expected ever goes non-finite.
- observed_mask = (~np.isnan(matrix)) & np.isfinite(residual) & np.isfinite(expected)
- scored_post_count = int(observed_mask.any(axis=1).sum())
- scored_item_count = int(observed_mask.any(axis=0).sum())
- keep_person, keep_item = _complete_case_masks(observed_mask)
- map_post_count = int(keep_person.sum())
- map_item_count = int(keep_item.sum())
- return LeftoverMapCoverage(
- map_post_count=map_post_count,
- scored_post_count=scored_post_count,
- map_item_count=map_item_count,
- scored_item_count=scored_item_count,
- incomplete_post_count=scored_post_count - map_post_count,
- incomplete_item_count=scored_item_count - map_item_count,
- )
-
-
-def _complete_case_masks(observed: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
- """Drop incomplete rows, then incomplete columns among remaining rows."""
- keep_person = observed.any(axis=1)
- keep_item = observed.any(axis=0)
- if np.any(keep_item):
- keep_person = keep_person & observed[:, keep_item].all(axis=1)
- if np.any(keep_person):
- keep_item = keep_item & observed[keep_person, :].all(axis=0)
- else:
- keep_item = np.zeros_like(keep_item)
- return keep_person, keep_item
-
-
-def _complete_case_positions(
- residual: np.ndarray,
- center: float,
- keep_person: np.ndarray,
- keep_item: np.ndarray,
-) -> tuple[np.ndarray | None, np.ndarray | None, np.ndarray]:
- """Gabriel coordinates on the complete-case residual rectangle only."""
- person_index = np.flatnonzero(keep_person)
- item_index = np.flatnonzero(keep_item)
- empty_singular = np.zeros(0, dtype=np.float64)
- if person_index.size == 0 or item_index.size == 0:
- return None, None, empty_singular
- filled = residual[np.ix_(person_index, item_index)] - center
- return _leftover_map_positions(filled)
-
-
-def _leftover_map_positions(
- filled: np.ndarray,
-) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
- """Gabriel coordinates ordered by descending singular value.
- NumPy's SVD contract returns singular values largest-first, so filtering
- by the numerical floor preserves a prefix and the first two columns remain
- the two leading leftover-map axes. Rank-0 residuals collapse to the origin.
- """
- n_persons, n_items = filled.shape
- empty_singular = np.zeros(0, dtype=np.float64)
- if n_persons == 0 or n_items == 0 or not np.any(np.abs(filled) > _LEFTOVER_SINGULAR_FLOOR):
- return (
- np.zeros((n_persons, 1), dtype=np.float64),
- np.zeros((n_items, 1), dtype=np.float64),
- empty_singular,
- )
- left, singular, right = np.linalg.svd(filled, full_matrices=False)
- keep = singular > _LEFTOVER_SINGULAR_FLOOR
- if not np.any(keep):
- return (
- np.zeros((n_persons, 1), dtype=np.float64),
- np.zeros((n_items, 1), dtype=np.float64),
- empty_singular,
- )
- scale = np.sqrt(singular[keep])
- person_pos = left[:, keep] * scale
- item_pos = right[keep, :].T * scale
- return person_pos, item_pos, singular[keep]
-
-
-def _pad_map_axes(positions: np.ndarray) -> np.ndarray:
- """Pad or truncate Gabriel coordinates to two leftover-map axes.
+def _pair(
+ kind: str,
+ row: tuple[float, str, str, float, float, float, float, float | None, float],
+ rank: int,
+) -> LeftoverPair:
+ """Attach product identifiers to one Rust-computed candidate cell."""
- Unused axes pad with zero rather than inventing a second component.
- Hidden SVD axes after the second are dropped so reconstruction is
- ``ξ_{1:2} · ζ_{1:2}``, not the full-rank inner product. That
- reconstruction is persisted with unexplained leftover and cross share so
- the raw-residual identity remains auditable.
- """
- padded = np.zeros((positions.shape[0], _LEFTOVER_MAP_AXES), dtype=np.float64)
- width = min(_LEFTOVER_MAP_AXES, positions.shape[1])
- padded[:, :width] = positions[:, :width]
- return padded
+ return LeftoverPair(
+ pair_kind=kind,
+ post_id=row[1],
+ criterion_code=row[2],
+ leftover_distance=row[0],
+ leftover_residual=row[3],
+ observed_response=row[4],
+ expected_response=row[5],
+ leftover_map_rank=rank,
+ leftover_map_unexplained=row[6],
+ leftover_map_cross_share=row[7],
+ leftover_map_reconstruction=row[8],
+ )
diff --git a/lineageweave/period_report.py b/lineageweave/period_report.py
index 5e3244c1c..09dc6f838 100644
--- a/lineageweave/period_report.py
+++ b/lineageweave/period_report.py
@@ -24,8 +24,8 @@
share is Gabriel inertia ``σ_k² / Σ_j σ_j²`` of residual SVD axes 1
and 2 (ADR 0148). Complete-case coverage (ADR 0168) names how many
scored posts entered the factorization; incomplete rows are excluded,
-never filled with zero. ``fast-mlsirm`` has no leftover-pair API; this
-module does not invent a second IRT fit and does not fork LSIRM.
+never filled with zero. ``fast-mlsirm.residual_interaction_map`` owns that
+arithmetic in Rust; this module only attaches product identifiers.
This module is pure compute. Persistence lives in
``backend/app/report_ingestion.py``. TEPP is not used here; temporal
@@ -43,6 +43,7 @@
fixed_item_calibration_diagnostics,
information_polytomous,
polytomous_category_probabilities,
+ polytomous_expected_response,
score_polytomous,
validate_irt_response_matrix,
)
@@ -185,28 +186,6 @@ def item_bank_from_fit(fit: PolytomousFit, item_codes: tuple[str, ...], source_p
)
-def observed_response_loglik(matrix: np.ndarray, probs: np.ndarray) -> float:
- """Sum log P(y_ij) over observed cells; missing cells are skipped."""
- loglik = 0.0
- n_persons, n_items = matrix.shape
- for person in range(n_persons):
- for item in range(n_items):
- category = matrix[person, item]
- if np.isnan(category):
- continue
- index = int(category)
- loglik += float(np.log(max(probs[person, item, index], 1e-12)))
- return loglik
-
-
-def expected_category_matrix(matrix: np.ndarray, probs: np.ndarray) -> np.ndarray:
- """E[Y_pi] = sum_k k P(Y=k | θ_p, item_i); missing cells stay NaN."""
- n_categories = probs.shape[2]
- categories = np.arange(n_categories, dtype=np.float64)
- expected = np.tensordot(probs, categories, axes=([2], [0]))
- return np.where(np.isnan(matrix), np.nan, expected)
-
-
def leftover_map_for_fit(
post_ids: list[str],
item_codes: tuple[str, ...],
@@ -216,8 +195,7 @@ def leftover_map_for_fit(
fit: PolytomousFit,
) -> tuple[tuple[LeftoverPair, ...], tuple[LeftoverMapAxis, ...]]:
"""Leftover pairs and leftover-map axis share from fitted GRM/GPCM."""
- probs = _category_probabilities(model, theta, fit)
- expected = expected_category_matrix(matrix, probs)
+ expected = polytomous_expected_response(fit, theta)
return leftover_map_from_residual(post_ids, item_codes, matrix, expected)
@@ -243,8 +221,7 @@ def leftover_map_coverage_for_fit(
fit: PolytomousFit,
) -> LeftoverMapCoverage:
"""Complete-case leftover-map coverage from the fitted main effects."""
- probs = _category_probabilities(model, theta, fit)
- expected = expected_category_matrix(matrix, probs)
+ expected = polytomous_expected_response(fit, theta)
return leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
@@ -368,7 +345,7 @@ def score_period_on_bank(
mean_theta_sd=float(theta.std(ddof=0)),
post_count=len(post_ids),
item_count=len(item_bank.item_codes),
- fit_loglik=observed_response_loglik(matrix, probs),
+ fit_loglik=float(diagnostics.best["heldout_loglik"]),
fit_converged=True,
calibration_score=float(diagnostics.best["calibration_score"]),
member_scores=_member_scores(post_ids, scores),
diff --git a/pyproject.toml b/pyproject.toml
index bc85fb6d0..091ac2ce6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -56,7 +56,7 @@ backend = [
# yet; pinned to a specific commit, same pattern as rankweave. Ships a
# PyO3/maturin Rust core with no fallback wheel, so building this from
# source needs a Rust toolchain -- see backend/Dockerfile.
- "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@d025b7d237d8db7ca97a5611606c6285d5870895",
+ "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@256470c7d1df4910a018841499a74d88b751a774",
]
[tool.setuptools.packages.find]
diff --git a/tests/test_leftover_pairs.py b/tests/test_leftover_pairs.py
index a1080e172..dfd0eec61 100644
--- a/tests/test_leftover_pairs.py
+++ b/tests/test_leftover_pairs.py
@@ -1,615 +1,109 @@
-"""Leftover post–criterion pairs after the main-effect IRT.
-
-Covers ADR 0048 as amended by ADR 0119, ADR 0148, ADR 0163, ADR 0164,
-ADR 0182, and ADR 0185.
-
-Uses a constructed residual matrix so the closest and farthest pair
-are known without calling ``fit_polytomous``. Loads
-``leftover_pairs.py`` by path so package ``__init__`` / ``period_report``
-/ ``fast_mlsirm`` stay out of this module.
-"""
+"""Consumer tests for the Rust-owned residual interaction-map boundary."""
from __future__ import annotations
-import ast
-import importlib.util
-import sys
-from pathlib import Path
+import inspect
import numpy as np
import pytest
-_LEFTOVER_PATH = Path(__file__).resolve().parents[1] / "lineageweave" / "leftover_pairs.py"
-_LEFTOVER_SINGULAR_FLOOR = 1e-12
-_LEFTOVER_MAP_AXES = 2
-
-
-def _load_leftover():
- """Load only the dependency-light leftover module under test."""
- source = _LEFTOVER_PATH.read_text(encoding="utf-8")
- imported = []
- for node in ast.parse(source).body:
- if isinstance(node, ast.Import):
- imported.extend(alias.name.split(".", 1)[0] for alias in node.names)
- elif isinstance(node, ast.ImportFrom):
- imported.append((node.module or "").split(".", 1)[0])
- assert "fast_mlsirm" not in imported
- assert "period_report" not in imported
- spec = importlib.util.spec_from_file_location("lineageweave_leftover_pairs", _LEFTOVER_PATH)
- assert spec is not None and spec.loader is not None
- module = importlib.util.module_from_spec(spec)
- sys.modules[spec.name] = module
- spec.loader.exec_module(module)
- return module
-
-
-leftover = _load_leftover()
-PAIR_KIND_CLOSEST = leftover.PAIR_KIND_CLOSEST
-PAIR_KIND_FARTHEST = leftover.PAIR_KIND_FARTHEST
-leftover_pairs_from_residual = leftover.leftover_pairs_from_residual
-leftover_map_from_residual = leftover.leftover_map_from_residual
-leftover_map_axes_from_singular = leftover.leftover_map_axes_from_singular
-leftover_map_coverage_from_residual = leftover.leftover_map_coverage_from_residual
-
-
-def _assert_residual_reconciles(pair) -> None:
- """Assert the persisted residual is exactly grounded in named Y and E."""
- assert pair.leftover_residual == pytest.approx(
- pair.observed_response - pair.expected_response, abs=1e-6
- )
-
-
-def _assert_never_persists_hidden_shares(pair) -> None:
- """The cross-share/reconstruction path never persists unsupported shares."""
- assert not hasattr(pair, "leftover_map_explained_share")
- assert not hasattr(pair, "leftover_map_unexplained_share")
-
-
-def _gabriel_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
- """Independent Gabriel coordinates used to prove leftover_distance axes."""
- left, singular, right = np.linalg.svd(filled, full_matrices=False)
- keep = singular > _LEFTOVER_SINGULAR_FLOOR
- scale = np.sqrt(singular[keep])
- return left[:, keep] * scale, right[keep, :].T * scale
-
-
-def _pad_map_axes(positions: np.ndarray) -> np.ndarray:
- """Independently pad or truncate coordinates to two map axes."""
- padded = np.zeros((positions.shape[0], _LEFTOVER_MAP_AXES), dtype=np.float64)
- width = min(_LEFTOVER_MAP_AXES, positions.shape[1])
- padded[:, :width] = positions[:, :width]
- return padded
-
-
-def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None:
- """A rank-1 leftover spike puts the aligned cell closest and the opposed cell farthest."""
- post_ids = ["post-a", "post-b", "post-c"]
- item_codes = ("item_near", "item_mid", "item_far")
- matrix = np.array(
- [
- [2.0, 0.0, -2.0],
- [0.0, 0.0, 0.0],
- [-2.0, 0.0, 2.0],
- ],
- dtype=np.float64,
- )
- expected = np.zeros_like(matrix)
- pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected)
- assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST]
- closest, farthest = pairs
- assert closest.leftover_distance < farthest.leftover_distance
- assert closest.leftover_distance == pytest.approx(0.0, abs=1e-9)
- assert (farthest.post_id, farthest.criterion_code) in {
- ("post-a", "item_far"),
- ("post-c", "item_near"),
- }
- assert farthest.leftover_residual == pytest.approx(-2.0)
- assert farthest.observed_response == pytest.approx(-2.0)
- assert farthest.expected_response == pytest.approx(0.0)
- assert farthest.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6)
- assert closest.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6)
- assert farthest.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6)
- # Closest is the origin cell (R = 0, R̂ = 0, U = 0): 0/0 stores 0.
- assert closest.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6)
- # Rank-1 reconstructed opposed cell: U = 0 so x = 0.
- assert farthest.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6)
- assert closest.leftover_map_reconstruction == pytest.approx(0.0, abs=1e-6)
- assert farthest.leftover_map_reconstruction == pytest.approx(-2.0, abs=1e-6)
- for pair in pairs:
- _assert_residual_reconciles(pair)
- _assert_never_persists_hidden_shares(pair)
- assert pair.leftover_map_rank == 1
- coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
- assert coverage.map_post_count == 3
- assert coverage.scored_post_count == 3
- assert coverage.incomplete_post_count == 0
- assert coverage.map_item_count == 3
- assert coverage.scored_item_count == 3
-
+from lineageweave import leftover_pairs as leftover
-def test_zero_residual_still_emits_stable_leftover_pairs() -> None:
- """A rank-zero map retains deterministic closest and farthest rows."""
- post_ids = ["alpha-post", "beta-post"]
- item_codes = ("item_one", "item_two")
- matrix = np.ones((2, 2), dtype=np.float64)
- expected = np.ones((2, 2), dtype=np.float64)
- pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected)
- assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST]
- assert pairs[0].leftover_distance == pytest.approx(0.0)
- assert pairs[1].leftover_distance == pytest.approx(0.0)
- assert pairs[0].post_id == "alpha-post"
- assert pairs[0].criterion_code == "item_one"
- assert pairs[0].observed_response == pytest.approx(1.0)
- assert pairs[0].expected_response == pytest.approx(1.0)
- assert pairs[1].post_id == "beta-post"
- assert pairs[1].criterion_code == "item_two"
- assert pairs[0].leftover_map_unexplained == pytest.approx(0.0)
- assert pairs[1].leftover_map_unexplained == pytest.approx(0.0)
- assert pairs[0].leftover_map_cross_share == pytest.approx(0.0)
- assert pairs[1].leftover_map_cross_share == pytest.approx(0.0)
- assert pairs[0].leftover_map_reconstruction == pytest.approx(0.0)
- assert pairs[1].leftover_map_reconstruction == pytest.approx(0.0)
- for pair in pairs:
- _assert_residual_reconciles(pair)
- assert pair.leftover_map_rank == 0
- coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
- assert coverage.map_post_count == 2
- assert coverage.scored_post_count == 2
- assert coverage.incomplete_post_count == 0
-
-
-def test_rank_zero_nonzero_constant_residual_keeps_raw_identity() -> None:
- """Centering a constant nonzero residual gives R̂=0 while U remains R."""
- matrix = np.ones((2, 2), dtype=np.float64)
- pairs = leftover_pairs_from_residual(
- ["post-a", "post-b"],
- ("item-a", "item-b"),
- matrix,
- np.zeros_like(matrix),
- )
- assert pairs
- for pair in pairs:
- assert pair.leftover_map_rank == 0
- assert pair.leftover_residual == pytest.approx(1.0)
- assert pair.leftover_map_reconstruction == pytest.approx(0.0)
- assert pair.leftover_map_unexplained == pytest.approx(1.0)
- assert pair.leftover_map_unexplained + pair.leftover_map_reconstruction == pytest.approx(
- pair.leftover_residual
- )
+def test_rust_map_projects_pairs_axes_and_coverage() -> None:
+ """Rust evidence is attached to stable product identifiers without recomputation."""
-
-def test_partial_observation_does_not_treat_missing_as_zero_residual() -> None:
- """A missing cell must not enter the Gabriel factorization as 0."""
- post_ids = ["aligned-post", "opposed-post", "sparse-post"]
- item_codes = ("item_near", "item_far")
- matrix = np.array(
- [
- [2.0, -2.0],
- [-2.0, 2.0],
- [2.0, np.nan],
- ],
- dtype=np.float64,
- )
+ matrix = np.array([[2.0, -2.0], [-2.0, 2.0], [2.0, np.nan]])
expected = np.zeros_like(matrix)
- pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected)
- assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST]
- closest, farthest = pairs
- assert {pair.post_id for pair in pairs} <= {"aligned-post", "opposed-post"}
- assert closest.leftover_distance == pytest.approx(0.0, abs=1e-9)
- assert farthest.leftover_distance == pytest.approx(2.0 * np.sqrt(2.0), rel=1e-6)
- assert farthest.leftover_residual == pytest.approx(-2.0)
- assert (farthest.post_id, farthest.criterion_code) in {
- ("aligned-post", "item_far"),
- ("opposed-post", "item_near"),
- }
- for pair in pairs:
- _assert_residual_reconciles(pair)
- assert pair.leftover_map_rank == 1
- assert pair.leftover_map_unexplained == pytest.approx(0.0, abs=1e-6)
- assert pair.leftover_map_cross_share == pytest.approx(0.0, abs=1e-6)
- coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
- assert coverage.map_post_count == 2
- assert coverage.scored_post_count == 3
- assert coverage.incomplete_post_count == 1
- assert coverage.map_item_count == 2
- assert coverage.scored_item_count == 2
- assert coverage.incomplete_item_count == 0
-
-
-def test_leftover_is_empty_without_observed_cells() -> None:
- """An entirely missing response matrix yields no invented pair."""
- post_ids = ["post-empty"]
- item_codes = ("item_one",)
- matrix = np.array([[np.nan]], dtype=np.float64)
- expected = np.array([[0.0]], dtype=np.float64)
- assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == ()
- coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
- assert coverage.map_post_count == 0
- assert coverage.scored_post_count == 0
- assert coverage.incomplete_post_count == 0
- assert coverage.map_item_count == 0
- assert coverage.scored_item_count == 0
- assert coverage.incomplete_item_count == 0
+ post_ids = ["post-a", "post-b", "post-sparse"]
+ item_codes = ("item-a", "item-b")
-
-def test_leftover_residual_equals_observed_minus_expected() -> None:
- """Named Y and E on leftover pairs must reconcile to R = Y − E."""
- post_ids = ["public-post", "spec-post"]
- item_codes = ("sales_lead_specificity", "general_sentiment_negative")
- matrix = np.array(
- [
- [2.4, 0.0],
- [0.0, 0.9],
- ],
- dtype=np.float64,
- )
- expected = np.array(
- [
- [2.0, 0.0],
- [0.0, 2.0],
- ],
- dtype=np.float64,
- )
- pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected)
- assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST]
- by_cell = {(pair.post_id, pair.criterion_code): pair for pair in pairs}
- closest_cell = by_cell[("public-post", "sales_lead_specificity")]
- farthest_cell = by_cell[("spec-post", "general_sentiment_negative")]
- assert closest_cell.observed_response == pytest.approx(2.4)
- assert closest_cell.expected_response == pytest.approx(2.0)
- assert closest_cell.leftover_residual == pytest.approx(0.4)
- assert farthest_cell.observed_response == pytest.approx(0.9)
- assert farthest_cell.expected_response == pytest.approx(2.0)
- assert farthest_cell.leftover_residual == pytest.approx(-1.1)
- for pair in pairs:
- _assert_residual_reconciles(pair)
-
-
-def test_leftover_residual_rejects_database_tolerance_boundary() -> None:
- """Python must reject the exact boundary excluded by the DB check."""
- with pytest.raises(ValueError, match="observed Y minus expected E"):
- leftover._candidate_row(
- ["public-post"],
- ("sales_lead_specificity",),
- np.array([[0.0]]),
- np.array([[0.0]]),
- np.array([[1e-6]]),
- 0,
- 0,
- 0.0,
- None,
- None,
- None,
- )
-
-
-def test_leftover_pairs_empty_without_complete_case_map() -> None:
- """No complete-case rectangle (ADR 0168): no stand-in pair, coverage instead."""
- post_ids = ["sparse-a", "sparse-b"]
- item_codes = ("item_near", "item_far")
- matrix = np.array(
- [
- [2.0, np.nan],
- [np.nan, -2.0],
- ],
- dtype=np.float64,
+ pairs, axes = leftover.leftover_map_from_residual(
+ post_ids, item_codes, matrix, expected
)
- expected = np.zeros_like(matrix)
- assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == ()
- coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
- assert coverage.map_post_count == 0
- assert coverage.scored_post_count == 2
- assert coverage.incomplete_post_count == 2
-
-
-def test_rank_one_nonzero_center_is_disclosed_by_raw_residual_cross_share() -> None:
- """Raw-residual cross share retains the mean omitted by centered SVD."""
- post_ids = ["post-a", "post-b", "post-c"]
- item_codes = ("item_near", "item_mid", "item_far")
- matrix = np.array(
- [
- [5.0, 3.0, 1.0],
- [3.0, 3.0, 3.0],
- [1.0, 3.0, 5.0],
- ],
- dtype=np.float64,
+ coverage = leftover.leftover_map_coverage_from_residual(
+ post_ids, item_codes, matrix, expected
)
- expected = np.zeros_like(matrix)
- assert float(np.mean(matrix)) == pytest.approx(3.0)
- pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected)
- assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST]
- closest, farthest = pairs
- person_full, item_full, _singular = leftover._leftover_map_positions(matrix - np.mean(matrix))
- reconstruction = leftover._pad_map_axes(person_full) @ leftover._pad_map_axes(item_full).T
- post_index = {post_id: index for index, post_id in enumerate(post_ids)}
- item_index = {code: index for index, code in enumerate(item_codes)}
- for pair in pairs:
- residual = pair.leftover_residual
- recon = float(reconstruction[post_index[pair.post_id], item_index[pair.criterion_code]])
- expected_share = 0.0 if residual == 0.0 and recon == 0.0 else 2.0 * recon * (residual - recon) / residual**2
- assert pair.leftover_map_cross_share == pytest.approx(expected_share, abs=1e-6)
- assert farthest.leftover_residual != pytest.approx(0.0)
- assert farthest.leftover_map_cross_share != pytest.approx(farthest.leftover_residual)
- for pair in pairs:
- _assert_residual_reconciles(pair)
- _assert_never_persists_hidden_shares(pair)
-
-def test_rank_one_leftover_map_puts_all_inertia_on_axis_one() -> None:
- """A rank-1 residual must report leftover-map share 1 on axis 1, 0 on axis 2."""
- post_ids = ["post-a", "post-b", "post-c"]
- item_codes = ("item_near", "item_mid", "item_far")
- matrix = np.array(
- [
- [2.0, 0.0, -2.0],
- [0.0, 0.0, 0.0],
- [-2.0, 0.0, 2.0],
- ],
- dtype=np.float64,
- )
- expected = np.zeros_like(matrix)
- pairs, axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected)
- assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST]
+ assert [pair.pair_kind for pair in pairs] == ["closest", "farthest"]
+ assert {pair.post_id for pair in pairs} <= {"post-a", "post-b"}
assert [axis.axis_index for axis in axes] == [1, 2]
assert axes[0].leftover_share == pytest.approx(1.0)
assert axes[1].leftover_share == pytest.approx(0.0)
- assert axes[0].leftover_singular_value > 0.0
- assert axes[1].leftover_singular_value == pytest.approx(0.0)
- assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == pairs
-
-
-def test_zero_residual_emits_two_zero_share_leftover_map_axes() -> None:
- post_ids = ["alpha-post", "beta-post"]
- item_codes = ("item_one", "item_two")
- matrix = np.ones((2, 2), dtype=np.float64)
- expected = np.ones((2, 2), dtype=np.float64)
- pairs, axes = leftover_map_from_residual(post_ids, item_codes, matrix, expected)
- assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST]
- assert [axis.axis_index for axis in axes] == [1, 2]
- assert axes[0].leftover_share == pytest.approx(0.0)
- assert axes[1].leftover_share == pytest.approx(0.0)
- assert axes[0].leftover_singular_value == pytest.approx(0.0)
- assert axes[1].leftover_singular_value == pytest.approx(0.0)
-
-
-def test_leftover_map_axes_from_singular_use_gabriel_inertia() -> None:
- """Share is σ_k² / Σ_j σ_j² from the actual singular values, never a leftover score."""
- singular = np.array([3.0, 1.0, 0.5], dtype=np.float64)
- total = float(np.sum(singular * singular))
- axes = leftover_map_axes_from_singular(singular)
- assert [axis.axis_index for axis in axes] == [1, 2]
- assert axes[0].leftover_singular_value == pytest.approx(3.0)
- assert axes[1].leftover_singular_value == pytest.approx(1.0)
- assert axes[0].leftover_share == pytest.approx(9.0 / total)
- assert axes[1].leftover_share == pytest.approx(1.0 / total)
- assert leftover_map_axes_from_singular(np.zeros(0))[0].leftover_share == pytest.approx(0.0)
- assert leftover_map_from_residual(
- ["post-empty"],
- ("item_one",),
- np.array([[np.nan]], dtype=np.float64),
- np.array([[0.0]], dtype=np.float64),
- ) == ((), ())
-
-
-def test_rank_four_pair_distances_match_two_dimensional_gabriel_coords() -> None:
- """Jeon leftover_distance is Euclidean on the 2D map, not the full SVD rank."""
- post_ids = ["post-a", "post-b", "post-c", "post-d"]
- item_codes = ("item-a", "item-b", "item-c", "item-d")
- matrix = np.array(
- [
- [4.0, 1.0, 0.0, -1.0],
- [0.0, 3.0, 1.0, -2.0],
- [-2.0, 0.0, 2.0, 1.0],
- [1.0, -1.0, 0.0, 4.0],
- ],
- dtype=np.float64,
- )
- expected = np.zeros_like(matrix)
- filled = matrix - float(np.mean(matrix))
- person_full, item_full = _gabriel_positions(filled)
- assert person_full.shape[1] == 4
- person_map = _pad_map_axes(person_full)
- item_map = _pad_map_axes(item_full)
- full_distances = np.linalg.norm(person_full[:, None, :] - item_full[None, :, :], axis=2)
- map_distances = np.linalg.norm(person_map[:, None, :] - item_map[None, :, :], axis=2)
- assert float(np.max(np.abs(full_distances - map_distances))) > 1e-6
-
- pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected)
- assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST]
- post_index = {post_id: index for index, post_id in enumerate(post_ids)}
- item_index = {code: index for index, code in enumerate(item_codes)}
+ assert coverage == leftover.LeftoverMapCoverage(2, 3, 2, 2, 1, 0)
for pair in pairs:
- assert pair.leftover_map_rank == 4
- person = post_index[pair.post_id]
- item = item_index[pair.criterion_code]
- assert pair.leftover_distance == pytest.approx(float(map_distances[person, item]))
- assert pair.leftover_distance != pytest.approx(
- float(full_distances[person, item]), abs=1e-9
+ assert pair.leftover_residual == pytest.approx(
+ pair.observed_response - pair.expected_response
)
-
- farthest_map = np.unravel_index(int(np.argmax(map_distances)), map_distances.shape)
- farthest = pairs[1]
- assert (post_index[farthest.post_id], item_index[farthest.criterion_code]) == farthest_map
+ assert pair.leftover_map_unexplained is not None
+ assert pair.leftover_map_reconstruction is not None
-def test_unexplained_and_cross_share_are_identity_remainder_terms() -> None:
- """Unexplained U is R − R̂; cross share is 2 R̂ U / R². Neither is R or d.
+def test_rank_zero_keeps_deterministic_pairs_without_inventing_an_axis() -> None:
+ """The product selection remains deterministic when Rust reports rank zero."""
- Uses the same rank-4 matrix as the two-axis distance proof above: R̂ is
- the two-axis Gabriel reconstruction ``person_map @ item_map.T``, built
- from the same padded coordinates ``leftover_distance`` already uses.
- """
- post_ids = ["post-a", "post-b", "post-c", "post-d"]
- item_codes = ("item-a", "item-b", "item-c", "item-d")
- matrix = np.array(
- [
- [4.0, 1.0, 0.0, -1.0],
- [0.0, 3.0, 1.0, -2.0],
- [-2.0, 0.0, 2.0, 1.0],
- [1.0, -1.0, 0.0, 4.0],
- ],
- dtype=np.float64,
+ matrix = np.ones((2, 2))
+ pairs, axes = leftover.leftover_map_from_residual(
+ ["alpha", "beta"], ("one", "two"), matrix, matrix
)
- expected = np.zeros_like(matrix)
- center = float(np.mean(matrix))
- filled = matrix - center
- person_full, item_full, singular = leftover._leftover_map_positions(filled)
- rank = int(singular.size)
- assert person_full.shape[1] >= 3
- person_map = leftover._pad_map_axes(person_full)
- item_map = leftover._pad_map_axes(item_full)
- reconstruction = person_map @ item_map.T
- full_inner = person_full @ item_full.T
- map_distances = np.linalg.norm(person_map[:, None, :] - item_map[None, :, :], axis=2)
- assert float(np.max(np.abs(reconstruction - filled))) > 1e-6
- assert float(np.max(np.abs(reconstruction - full_inner))) > 1e-6
- assert abs(center) > 1e-6
-
- pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected)
- assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST]
- post_index = {post_id: index for index, post_id in enumerate(post_ids)}
- item_index = {code: index for index, code in enumerate(item_codes)}
- saw_nonzero_cross = False
- for pair in pairs:
- person = post_index[pair.post_id]
- item = item_index[pair.criterion_code]
- recon = float(reconstruction[person, item])
- # Raw unexplained leftover U = R − R̂ (ADR 0182).
- expected_unexplained = float(pair.leftover_residual) - recon
- assert pair.leftover_map_unexplained == pytest.approx(expected_unexplained)
- assert pair.leftover_map_unexplained != pytest.approx(pair.leftover_residual)
- assert pair.leftover_map_unexplained != pytest.approx(pair.leftover_distance)
- assert pair.leftover_map_reconstruction == pytest.approx(recon)
- assert pair.leftover_map_unexplained + pair.leftover_map_reconstruction == pytest.approx(
- pair.leftover_residual
- )
- # Raw-residual cross share x = 2 R̂ U / R² (ADR 0185).
- residual = float(pair.leftover_residual)
- expected_share = (2.0 * recon * expected_unexplained) / (residual * residual)
- explained_share = (recon * recon) / (residual * residual)
- unexplained_share = (expected_unexplained * expected_unexplained) / (residual * residual)
- assert pair.leftover_map_cross_share == pytest.approx(expected_share)
- assert explained_share + unexplained_share + expected_share == pytest.approx(1.0)
- if abs(expected_share) > 1e-6:
- saw_nonzero_cross = True
- assert pair.leftover_map_cross_share != pytest.approx(pair.leftover_residual)
- assert pair.leftover_map_cross_share != pytest.approx(pair.leftover_distance)
- # Distance is Euclidean on the two leftover-map axes (ADR 0119), the
- # same basis the reconstruction above uses -- not the full-rank
- # Gabriel inner product.
- assert pair.leftover_distance == pytest.approx(float(map_distances[person, item]))
- assert pair.leftover_map_rank == rank
- _assert_never_persists_hidden_shares(pair)
- assert saw_nonzero_cross
-
-
-def test_cross_share_stores_negative_finite_identity_remainder() -> None:
- """A negative identity remainder is stored, never omitted or clamped."""
- assert leftover._leftover_map_cross_share(1.0, 2.0) == pytest.approx(-4.0)
- assert leftover._leftover_map_cross_share(2.0, 2.0) == pytest.approx(0.0)
- assert leftover._leftover_map_cross_share(0.0, 0.0) == pytest.approx(0.0)
- assert leftover._leftover_map_cross_share(float("nan"), 1.0) is None
- assert leftover._leftover_map_cross_share(1.0, float("inf")) is None
+ assert [(pair.post_id, pair.criterion_code) for pair in pairs] == [
+ ("alpha", "one"),
+ ("beta", "two"),
+ ]
+ assert all(pair.leftover_map_rank == 0 for pair in pairs)
+ assert all(pair.leftover_distance == pytest.approx(0.0) for pair in pairs)
+ assert all(axis.leftover_share == pytest.approx(0.0) for axis in axes)
-def test_pad_map_axes_truncates_hidden_svd_components() -> None:
- """Axes after the second leftover-map axis do not enter reconstruction."""
- padded = leftover._pad_map_axes(np.array([[1.0, 2.0, 9.0]], dtype=np.float64))
- assert padded.shape == (1, 2)
- assert padded[0].tolist() == pytest.approx([1.0, 2.0])
+def test_no_complete_case_rectangle_returns_coverage_without_pairs() -> None:
+ """Sparse evidence remains unavailable instead of becoming zero-filled math."""
-def test_rejects_response_and_expectation_shape_mismatches() -> None:
- """Scientific inputs must match their declared post and criterion axes."""
- with pytest.raises(ValueError, match="matrix shape"):
- leftover_pairs_from_residual(
- ["post-a"],
- ("item-a",),
- np.zeros((2, 1), dtype=np.float64),
- np.zeros((2, 1), dtype=np.float64),
- )
- with pytest.raises(ValueError, match="expected shape"):
- leftover_pairs_from_residual(
- ["post-a"],
- ("item-a",),
- np.zeros((1, 1), dtype=np.float64),
- np.zeros((1, 2), dtype=np.float64),
- )
-
-
-def test_nonfinite_map_distance_emits_no_pair(
- monkeypatch: pytest.MonkeyPatch,
+ matrix = np.array([[1.0, np.nan], [np.nan, 1.0]])
+ expected = np.zeros_like(matrix)
+ post_ids = ["post-a", "post-b"]
+ item_codes = ("item-a", "item-b")
+
+ assert leftover.leftover_pairs_from_residual(
+ post_ids, item_codes, matrix, expected
+ ) == ()
+ assert leftover.leftover_map_coverage_from_residual(
+ post_ids, item_codes, matrix, expected
+ ) == leftover.LeftoverMapCoverage(0, 2, 0, 2, 2, 2)
+
+
+@pytest.mark.parametrize(
+ ("matrix", "expected", "message"),
+ [
+ (np.zeros((1, 2)), np.zeros((1, 2)), "matrix shape"),
+ (np.zeros((1, 1)), np.zeros((2, 1)), "expected shape"),
+ ],
+)
+def test_identifier_and_matrix_shape_mismatch_fails_closed(
+ matrix: np.ndarray, expected: np.ndarray, message: str
) -> None:
- """An unusable factorization coordinate cannot become persisted distance."""
- monkeypatch.setattr(
- leftover,
- "_complete_case_positions",
- lambda *_args: (
- np.array([[np.inf]], dtype=np.float64),
- np.array([[-np.inf]], dtype=np.float64),
- np.array([1.0], dtype=np.float64),
- ),
- )
- pairs = leftover_pairs_from_residual(
- ["post-a"],
- ("item-a",),
- np.array([[1.0]], dtype=np.float64),
- np.array([[0.0]], dtype=np.float64),
- )
- assert pairs == ()
-
-
-def test_empty_observation_mask_has_no_complete_case_axes() -> None:
- """The complete-case helpers preserve an empty scientific boundary."""
- observed = np.zeros((1, 1), dtype=bool)
- keep_person, keep_item = leftover._complete_case_masks(observed)
- assert not keep_person.any()
- assert not keep_item.any()
- person_pos, item_pos, singular = leftover._complete_case_positions(
- np.zeros((1, 1), dtype=np.float64),
- 0.0,
- keep_person,
- keep_item,
- )
- assert person_pos is None
- assert item_pos is None
- assert singular.size == 0
+ """A result that cannot bind to product identifiers is rejected."""
+ with pytest.raises(ValueError, match=message):
+ leftover.leftover_map_from_residual(["post"], ("item",), matrix, expected)
-def test_leftover_map_rank_rejects_negative_rank() -> None:
- """Python must reject a leftover-map rank excluded by the DB check."""
- with pytest.raises(ValueError, match="non-negative integer"):
- leftover._pair_from_candidate(
- PAIR_KIND_CLOSEST,
- (0.0, "public-post", "sales_lead_specificity", 0.0, 1.0, 1.0, None),
- -1,
- )
+def test_owner_failure_is_not_replaced_by_local_math(monkeypatch: pytest.MonkeyPatch) -> None:
+ """A missing Rust result remains an explicit dependency failure."""
-def test_small_finite_residual_keeps_cross_share() -> None:
- """A tiny-but-finite residual keeps its cross share.
+ def unavailable(*_args: object, **_kwargs: object) -> object:
+ raise RuntimeError("compiled Rust core unavailable")
- Squaring before the floor made the effective threshold 1e-6, so
- R = 1e-7 with reconstruction 5e-8 collapsed to an omitted badge
- even though x = 0.5 is well-defined (coderabbit review thread).
- """
- share = leftover._leftover_map_cross_share(1e-7, 5e-8)
- assert share == pytest.approx(0.5)
+ monkeypatch.setattr(leftover, "residual_interaction_map", unavailable)
+ with pytest.raises(RuntimeError, match="compiled Rust core unavailable"):
+ leftover.leftover_map_from_residual(
+ ["post"], ("item",), np.zeros((1, 1)), np.zeros((1, 1))
+ )
-def test_leftover_is_unavailable_without_a_complete_case_rectangle() -> None:
- """Observed cells alone cannot invent Gabriel positions or map coverage."""
- post_ids = ["post-a", "post-b"]
- item_codes = ("item-one", "item-two")
- matrix = np.array([[1.0, np.nan], [np.nan, 1.0]], dtype=np.float64)
- expected = np.zeros_like(matrix)
+def test_consumer_contains_no_factorization_formula() -> None:
+ """Regression guard: numerical interaction-map policy stays upstream."""
- assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == ()
- coverage = leftover_map_coverage_from_residual(post_ids, item_codes, matrix, expected)
- assert coverage.map_post_count == 0
- assert coverage.scored_post_count == 2
- assert coverage.map_item_count == 0
- assert coverage.scored_item_count == 2
- assert coverage.incomplete_post_count == 2
- assert coverage.incomplete_item_count == 2
+ source = inspect.getsource(leftover)
+ for forbidden in ("np.linalg", "np.dot", "np.sqrt", "np.mean", "np.sum"):
+ assert forbidden not in source
diff --git a/uv.lock b/uv.lock
index 98ac77e48..9ca8e35cc 100644
--- a/uv.lock
+++ b/uv.lock
@@ -470,8 +470,8 @@ wheels = [
[[package]]
name = "fast-mlsirm"
-version = "0.8.0"
-source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=d025b7d237d8db7ca97a5611606c6285d5870895#d025b7d237d8db7ca97a5611606c6285d5870895" }
+version = "0.9.0"
+source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=256470c7d1df4910a018841499a74d88b751a774#256470c7d1df4910a018841499a74d88b751a774" }
dependencies = [
{ name = "numpy" },
]
@@ -655,7 +655,7 @@ requires-dist = [
{ name = "certifi", specifier = ">=2024.0.0" },
{ name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" },
{ name = "cryptography", specifier = ">=42.0" },
- { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=d025b7d237d8db7ca97a5611606c6285d5870895" },
+ { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=256470c7d1df4910a018841499a74d88b751a774" },
{ name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.141.1" },
{ name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.12.0" },
{ name = "opentelemetry-api", specifier = ">=1.30.0" },
From fa604e797c4746aadbb4819fff63726469a505f4 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 08:35:01 +0900
Subject: [PATCH 058/393] docs(changelog): keep provenance references under
changed
---
CHANGELOG.md | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fbef5686e..f7f1be1ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,9 @@ All notable changes to this project are documented here. Format follows
and the development test stack follows Starlette's maintained `httpx2`
`TestClient` contract. FastAPI 422 responses use the RFC 9110 constant, so
deprecation failures are repaired rather than suppressed.
+- ADRs 0011 and 0065 now include APA 7th References for the dated W3C
+ PROV-O and PROV-DM Recommendations (30 April 2013). Decisions are
+ unchanged.
### Added
@@ -125,10 +128,6 @@ All notable changes to this project are documented here. Format follows
authorized leftover store as the period-report list; they do not
invent a leftover score.
-- ADRs 0011 and 0065 now include APA 7th References for the dated W3C
- PROV-O and PROV-DM Recommendations (30 April 2013). Decisions are
- unchanged.
-
### Fixed
- Full-corpus Event Lineage rebuilds now count candidate pairs before provider
From dda5cf48d6008bf537df9cf8ce7df99f847958ff Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 08:44:32 +0900
Subject: [PATCH 059/393] fix(dashboard): preserve unavailable evidence state
---
backend/app/operations_dashboard.py | 10 +++++++++-
backend/app/post_content_worker.py | 3 ++-
lineageweave/period_report.py | 14 +++++++++++---
tests/test_operations_dashboard.py | 12 ++++++------
tests/test_post_content_worker.py | 2 ++
5 files changed, 30 insertions(+), 11 deletions(-)
diff --git a/backend/app/operations_dashboard.py b/backend/app/operations_dashboard.py
index ae8f3eb23..449d41757 100644
--- a/backend/app/operations_dashboard.py
+++ b/backend/app/operations_dashboard.py
@@ -282,7 +282,15 @@ async def fetch_operations_dashboard(
join source_post post on post.post_id = missing.post_id
where {visible}
and ($5::boolean is false or missing.case_kind_code = 'external_information')
- order by missing.post_id, missing.case_kind_code, missing.fact_type_code
+ union all
+ select fact.post_id, fact.case_kind_code, fact.fact_type_code
+ from operations_case_fact fact
+ join source_post post on post.post_id = fact.post_id
+ join source_post evidence_post on evidence_post.post_id = fact.evidence_post_id
+ where {visible}
+ and not ({visible_evidence})
+ and ($5::boolean is false or fact.case_kind_code = 'external_information')
+ order by post_id, case_kind_code, fact_type_code
""",
*args,
)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 10f8e958a..5cf7407b0 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -72,6 +72,7 @@ def can_see(row: asyncpg.Record) -> bool:
sources = await gather_chat_sources(conn, post_id, can_see, vision_client)
if not sources:
return ()
+ source_post_ids = [UUID(source.post_id) for source in sources]
source_times = {
str(row["post_id"]): (
row["observed_at"],
@@ -83,7 +84,7 @@ def can_see(row: asyncpg.Record) -> bool:
"select post_id, event_occurred_at, "
"coalesce(event_occurred_at, created_at) as observed_at "
"from source_post where post_id = any($1::uuid[])",
- [source.post_id for source in sources],
+ source_post_ids,
)
}
if any(source.post_id not in source_times for source in sources):
diff --git a/lineageweave/period_report.py b/lineageweave/period_report.py
index 09dc6f838..7e5c973f7 100644
--- a/lineageweave/period_report.py
+++ b/lineageweave/period_report.py
@@ -127,6 +127,14 @@ class PeriodReport:
leftover_map_coverage: LeftoverMapCoverage | None = None
+def _diagnostic_float(diagnostics: object, key: str) -> float:
+ """Read a required fast-mlsirm diagnostic without inventing a fallback."""
+ best = getattr(diagnostics, "best", None)
+ if not isinstance(best, dict) or key not in best:
+ raise RuntimeError(f"fast-mlsirm diagnostic contract missing {key!r}")
+ return float(best[key])
+
+
def assemble_response_matrix(
post_ids: list[str],
rows: list[tuple[str, str, int]],
@@ -297,7 +305,7 @@ def calibrate_period_report(
item_count=len(item_codes),
fit_loglik=float(fit.loglik),
fit_converged=bool(fit.converged),
- calibration_score=float(diagnostics.best["calibration_score"]),
+ calibration_score=_diagnostic_float(diagnostics, "calibration_score"),
member_scores=_member_scores(post_ids, scores),
item_bank=item_bank,
link_method=LINK_METHOD_FREE,
@@ -345,9 +353,9 @@ def score_period_on_bank(
mean_theta_sd=float(theta.std(ddof=0)),
post_count=len(post_ids),
item_count=len(item_bank.item_codes),
- fit_loglik=float(diagnostics.best["heldout_loglik"]),
+ fit_loglik=_diagnostic_float(diagnostics, "heldout_loglik"),
fit_converged=True,
- calibration_score=float(diagnostics.best["calibration_score"]),
+ calibration_score=_diagnostic_float(diagnostics, "calibration_score"),
member_scores=_member_scores(post_ids, scores),
item_bank=item_bank,
link_method=LINK_METHOD_FIPC,
diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py
index 4a5b9c0bf..c7df470c8 100644
--- a/tests/test_operations_dashboard.py
+++ b/tests/test_operations_dashboard.py
@@ -31,6 +31,12 @@ async def fetchrow(self, query: str, *args: object) -> dict[str, int]:
async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
self.queries.append((query, args))
+ if "operations_case_missing_fact missing" in query:
+ return [{
+ "post_id": "00000000-0000-0000-0000-000000000001",
+ "case_kind_code": "claim_investigation",
+ "fact_type_code": "sales_pool",
+ }]
if "operations_case_fact fact" in query:
return [
{
@@ -44,12 +50,6 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
"relation_target_kind_code": None,
}
]
- if "operations_case_missing_fact missing" in query:
- return [{
- "post_id": "00000000-0000-0000-0000-000000000001",
- "case_kind_code": "claim_investigation",
- "fact_type_code": "sales_pool",
- }]
if "operations_case_milestone milestone" in query:
return [
{
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index b14fe75df..3a9d122df 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -6,6 +6,7 @@
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from types import SimpleNamespace
+from uuid import UUID
import pytest
@@ -127,6 +128,7 @@ async def gather(*_args):
class SourceConnection(_Connection):
async def fetch(self, query: str, *_args: object):
assert "coalesce(event_occurred_at, created_at) as observed_at" in query
+ assert isinstance(_args[0][0], UUID)
return [{
"post_id": "00000000-0000-0000-0000-000000000001",
"event_occurred_at": observed_at,
From d70a778854e5250aa7008d5c51dac5e338a35cf0 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 17:01:42 -0700
Subject: [PATCH 060/393] refactor(rankings): consume RankWeave-owned RRF
evidence (#673)
Co-authored-by: Codex
---
CHANGELOG.md | 5 +
docs/adr/0024-rankweave-fusion-fail-closed.md | 17 +-
...-externalize-local-mathematical-compute.md | 5 +
...hon-mathematical-compute-boundary-audit.md | 2 +-
docs/product-technical-gap-baseline.md | 2 +-
lineageweave/rankweave_client.py | 147 +++++++++++-------
pyproject.toml | 2 +-
tests/test_rankweave_client.py | 62 ++++++--
uv.lock | 4 +-
9 files changed, 167 insertions(+), 79 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7f1be1ec..7c33678fe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,11 @@ All notable changes to this project are documented here. Format follows
### Changed
+- Rankings now call RankWeave's parameter-free classic RRF path when no
+ calibrated weights exist and project RankWeave-owned channel contributions;
+ the prior local contribution arithmetic and invalid all-ones call to the
+ convex-weight API were removed.
+
- Period-report leftover maps now consume fast-mlsirm's protected Rust
residual-interaction and expected-response contracts. Local Python Gabriel
SVD, distance, reconstruction, share, expectation, and duplicate likelihood
diff --git a/docs/adr/0024-rankweave-fusion-fail-closed.md b/docs/adr/0024-rankweave-fusion-fail-closed.md
index c03f73b45..15c875eee 100644
--- a/docs/adr/0024-rankweave-fusion-fail-closed.md
+++ b/docs/adr/0024-rankweave-fusion-fail-closed.md
@@ -28,18 +28,19 @@ tables, and does not bind the demo IdP to production Keyverse.
rank-only channels: temporal (newest first) and lexical (token
overlap with the synthetic demo query `pricing quote delivery`).
Hidden posts are omitted from every channel. Never invent a score.
-3. Fusion is weighted RRF with Cormack et al. (2009) η = 60 and
- Samuel et al. (2025) unequal-channel weights (`temporal` 0.25,
- `lexical` 0.75). The buyer sees 1-based `fused_rank` and the post
- title — not a TEPP theta.
+3. With no calibrated weights, fusion calls RankWeave's parameter-free
+ `reciprocal_rank_fuse` with Cormack et al. (2009) η = 60. An explicit
+ psychometrically estimated convex vector calls
+ `weighted_reciprocal_rank_fuse`. The buyer sees 1-based `fused_rank` and
+ the post title — not a TEPP theta.
4. After login, Rankings sits above Calendar. Unavailable copy is
**Rankings · RankWeave not available**. An accepted hit lists the
title; click opens that `source_post`.
5. Accepted hits also disclose owned-channel evidence (ADR 0167):
- 1-based `channel_rank` and Cormack contribution
- `weight / (η + rank)` for each channel the post actually appears
- in. Missing channels are omitted. RankWeave extra fields are
- ignored. Copy states this is not a calibrated score.
+ 1-based `channel_rank` and RankWeave-owned Cormack contribution for each
+ channel the post actually appears in. Missing channels are omitted.
+ Transport extra fields are ignored. Copy states this is not a calibrated
+ score.
## Consequences
diff --git a/docs/adr/0208-externalize-local-mathematical-compute.md b/docs/adr/0208-externalize-local-mathematical-compute.md
index 31d5b13ac..1c5d9adda 100644
--- a/docs/adr/0208-externalize-local-mathematical-compute.md
+++ b/docs/adr/0208-externalize-local-mathematical-compute.md
@@ -76,6 +76,11 @@ different responsibility.
Gabriel SVD, axis inertia, distance, reconstruction, unexplained residual,
cross share, and coverage arithmetic were deleted from LineageWeave Python.
Product-side identifier attachment and closest/farthest selection remain.
+- Rankings call RankWeave's classic or convex-weighted RRF owner path and
+ project its exact channel contributions. LineageWeave no longer evaluates
+ the reciprocal-rank contribution formula. RankWeave's Rust CPU/GPU migration
+ remains open, so this slice is owner-bound but not yet final execution-contract
+ compliance.
## Stacked delivery order
diff --git a/docs/doctoring/python-mathematical-compute-boundary-audit.md b/docs/doctoring/python-mathematical-compute-boundary-audit.md
index 74e129eec..108e136b2 100644
--- a/docs/doctoring/python-mathematical-compute-boundary-audit.md
+++ b/docs/doctoring/python-mathematical-compute-boundary-audit.md
@@ -28,7 +28,7 @@ does not relabel still-local Python paths as Rust/GPU compliant.
| `lineageweave/embedding_client.py` and `backend/app/post_chat_ingestion.py` | cosine similarity, vector norms, maximum semantic score | RankWeave retrieval-score contract | ranked evidence envelope over ABAC-visible semantic units | reconstruction text channel and Global Ask retrieval; embedding/post-chat tests |
| `lineageweave/knowledge_graph.py` | random walk with restart, convergence delta, adaptive relevance cutoff | RankWeave graph-ranking contract | ranked-node artifact with contribution and convergence evidence | related-person/entity API paths; knowledge-graph tests |
| `lineageweave/reconstruct.py` | channel-weight renormalization, candidate-score fusion and minimum-score decision | RankWeave fusion; TEPP supplies independent lineage criterion | accepted edge-ranking artifact; LineageWeave persists selected edge and channel provenance | lineage rebuild/start/seed/server; reconstruct, persistence, API tests |
-| `lineageweave/rankweave_client.py` | channel construction, token overlap, RRF weights and contribution arithmetic | RankWeave | strict ranking artifact exposing owner-computed contributions | `/api/rankings`, frontend Rankings; `tests/test_rankweave_client.py` and frontend tests |
+| `lineageweave/rankweave_client.py` | channel construction and token overlap remain; **owner-bound:** classic/weighted RRF and contribution arithmetic now come from RankWeave #47, whose Python core still awaits the required Rust CPU/GPU migration | RankWeave | Rust-backed strict ranking artifact exposing owner-computed contributions and owned channel construction | `/api/rankings`, frontend Rankings; `tests/test_rankweave_client.py` and frontend tests |
`lineageweave/post_evaluation.py` imports fast-mlsirm only for its published
judge contract and `to_irt_row` projection. It performs no fitted numerical
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 69074b9d0..3ac41f4a0 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -438,7 +438,7 @@ this file per §3.5 of the prior snapshot).
| SKOS organization aliases | Catalog binding and chip caption live on #480 / #482 | One catalog row per corroborated org; companion caption is hint-only until bound |
| Event Lineage evidence | Channel evidence and Allen relations live on #387 / #484 | Persist channel scores, explain them in the popup, never invent a fused score |
| Scientific measurement | Durable accepted TEPP receipts and LineageWeave #614's exact accepted snapshot/cutoff/run/pair-count consumer are protected; TEPP #237 remains open, so no registered producer artifact exists yet. #387 removes inferred/default persistence weights, but several older reconstruction tests still pass hand-authored numeric dictionaries that are not estimator evidence | Land TEPP #237 through its protected gate, then replace remaining reconstruction-test constants with provenance-bearing fast-mlsirm estimates over synthetic fixtures. Retain true-parameter RMSE recovery as the acceptance bar |
-| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Land this stacked consumer through protected gates, then migrate each remaining construct to its owning repository contract and require an empty transition inventory before claiming the boundary complete |
+| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stack also removes local RRF contribution arithmetic and corrects the invalid all-ones call to RankWeave's convex API through RankWeave #47 (`f92c3a2c`), but RankWeave #47 is still Python and therefore is not the final Rust CPU/GPU execution contract. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Land the owner and stacked consumer through protected gates; then move RankWeave's calculation core to Rust CPU/GPU and migrate each remaining construct to its owning repository contract before requiring an empty transition inventory |
| Asynchronous authorization | Protected `main` rebuilds Global Ask worker scope after the bearer token leaves the request; #468 now persists exact Keyverse organization/process-unit scope in 3NF child tables and intersects it with current affiliations | Land #468 through the protected gate; prove a second affiliation and a revoked process unit cannot widen delayed-job evidence |
| Planned-facility intent | Planned-facility relationship intent remains only on closed, unmerged #490; earlier stack-only merges were not protected delivery | Recreate the evidence-backed slice on a current base and land through protected `main` before a release claim |
| Accessibility and responsive UX | #602 delivered base post-detail modal semantics; #605 adds selected-post refocus, collapsed/hidden/inert/CSS-invisible focus exclusion across both modal types, readable evidence separators, focused tests, and desktop/mobile Storybook screenshots | Land #605 through the protected gate, then complete screen-reader and authenticated Playwright acceptance on the exact release head |
diff --git a/lineageweave/rankweave_client.py b/lineageweave/rankweave_client.py
index 4c157aa57..fde71810a 100644
--- a/lineageweave/rankweave_client.py
+++ b/lineageweave/rankweave_client.py
@@ -130,22 +130,25 @@ def ranking_channel_evidence(
) -> tuple["RankingChannelEvidence", ...]:
"""Explain one fused hit from owned channel ranks.
- Contribution is Cormack et al. (2009) weighted RRF:
- ``weight / (η + rank)`` with 1-based rank. A channel the post is
- missing from, or a non-positive weight, is omitted. RankWeave extra
- fields are ignored so a missing signal cannot be invented.
+ RankWeave owns the Cormack contribution arithmetic. A channel the post is
+ missing from, or a non-positive weight, is omitted. Transport extra fields
+ are ignored so a missing signal cannot be invented.
"""
- collected: list[tuple[str, int, float, float]] = []
- for signal_code, ordered_ids in channels.items():
- weight = float(weights.get(signal_code) or 0.0)
- if weight <= 0:
- continue
- try:
- channel_rank = [str(item_id) for item_id in ordered_ids].index(post_id) + 1
- except ValueError:
- continue
- contribution = weight / (eta + channel_rank)
- collected.append((signal_code, channel_rank, weight, contribution))
+ return _owner_channel_evidence(channels, weights, eta).get(post_id, ())
+
+
+def _evidence_from_owner_hit(hit: object) -> tuple["RankingChannelEvidence", ...]:
+ """Project one RankWeave result without recalculating a contribution."""
+ collected = [
+ (
+ str(contribution.channel_name),
+ int(contribution.rank),
+ float(contribution.weight),
+ float(contribution.contribution),
+ )
+ for contribution in getattr(hit, "channel_contributions", ())
+ if contribution.rank is not None and contribution.weight > 0
+ ]
collected.sort(key=lambda item: (-item[3], item[0]))
return tuple(
RankingChannelEvidence(
@@ -162,6 +165,19 @@ def ranking_channel_evidence(
)
+def _owner_channel_evidence(
+ channels: Mapping[str, Sequence[str]],
+ weights: Mapping[str, float],
+ eta: int,
+) -> dict[str, tuple["RankingChannelEvidence", ...]]:
+ """Index one RankWeave owner calculation by item identifier."""
+ return {
+ item_id: _evidence_from_owner_hit(hit)
+ for hit in _owner_rrf_hits(channels, weights, eta)
+ if (item_id := _item_id_from_hit(hit))
+ }
+
+
@dataclass(frozen=True)
class RankingChannelEvidence:
"""One owned-channel contribution to a fused ranking hit."""
@@ -216,6 +232,38 @@ def to_json(self) -> list[dict[str, Any]]:
return [item.to_json() for item in self.items]
+def _owner_rrf_hits(
+ channels: Mapping[str, Sequence[str]],
+ weights: Mapping[str, float],
+ eta: int,
+ *,
+ limit: int | None = None,
+) -> list[object]:
+ """Return RankWeave-owned classic or convex-weighted RRF results."""
+ try:
+ rw = _import_rankweave()
+ if all(float(weights.get(name) or 0.0) == 1.0 for name in channels):
+ return list(
+ rw.reciprocal_rank_fuse(
+ channels,
+ limit=limit,
+ rank_constant_eta=eta,
+ )
+ )
+ return list(
+ rw.weighted_reciprocal_rank_fuse(
+ channels,
+ weights,
+ limit=limit,
+ rank_constant_eta=eta,
+ )
+ )
+ except Exception as exc:
+ raise RankWeaveNotAvailable(
+ "rankweave_not_available: reciprocal-rank fusion failed"
+ ) from exc
+
+
def _item_id_from_hit(hit: object) -> str:
if isinstance(hit, Mapping):
return str(hit.get("item_id") or hit.get("post_id") or "").strip()
@@ -249,6 +297,11 @@ def project_ranking_list(
# Parameter-free classic RRF default (ADR 0200 point 1): every
# channel weighs 1.0 unless the caller passes an estimated set.
owned_weights = weights or {name: 1.0 for name in owned_channels}
+ evidence_by_post_id = _owner_channel_evidence(
+ owned_channels,
+ owned_weights,
+ DEFAULT_RANK_CONSTANT_ETA,
+ )
for hit in raw:
post_id = _item_id_from_hit(hit)
title = str(titles_by_id.get(post_id) or "").strip()
@@ -260,9 +313,7 @@ def project_ranking_list(
post_id=post_id,
post_title=title,
fused_rank=len(items) + 1,
- channel_evidence=ranking_channel_evidence(
- post_id, owned_channels, owned_weights
- ),
+ channel_evidence=evidence_by_post_id.get(post_id, ()),
)
)
return RankingList(items=tuple(items))
@@ -276,13 +327,6 @@ def __call__(
channels: dict[str, list[str]],
weights: dict[str, float],
) -> list[dict[str, Any]]:
- try:
- rw = _import_rankweave()
- except ImportError as exc:
- raise RankWeaveNotAvailable(
- "rankweave_not_available: rankweave package is not installed. "
- "Never invent a fused score."
- ) from exc
usable = {
name: [item_id for item_id in ranks if str(item_id).strip()]
for name, ranks in channels.items()
@@ -297,28 +341,13 @@ def __call__(
raise RankWeaveNotAvailable(
"rankweave_not_available: no positive channel weights remain"
)
- try:
- hits = rw.weighted_reciprocal_rank_fuse(
- usable,
- active_weights,
- limit=DEFAULT_RANKING_LIMIT,
- rank_constant_eta=DEFAULT_RANK_CONSTANT_ETA,
- )
- except TypeError:
- try:
- hits = rw.weighted_reciprocal_rank_fuse(
- usable,
- active_weights,
- limit=DEFAULT_RANKING_LIMIT,
- )
- except Exception as exc:
- raise RankWeaveNotAvailable(
- "rankweave_not_available: weighted_reciprocal_rank_fuse failed"
- ) from exc
- except Exception as exc:
- raise RankWeaveNotAvailable(
- "rankweave_not_available: weighted_reciprocal_rank_fuse failed"
- ) from exc
+ usable = {name: ranks for name, ranks in usable.items() if name in active_weights}
+ hits = _owner_rrf_hits(
+ usable,
+ active_weights,
+ DEFAULT_RANK_CONSTANT_ETA,
+ limit=DEFAULT_RANKING_LIMIT,
+ )
projected: list[dict[str, Any]] = []
for hit in hits:
item_id = _item_id_from_hit(hit)
@@ -353,12 +382,11 @@ def fuse_rankings(
) -> RankingList:
"""Fuse the channels; parameter-free classic RRF by default.
- No hand-picked weight exists (ADR 0200 point 1): without an
- explicit ``weights`` argument every channel gets weight 1.0,
- which reduces weighted RRF to Cormack et al.'s (2009)
- parameter-free reciprocal rank fusion -- the paper's own
- finding is that the unweighted form outperforms trained
- alternatives, so there is no arbitrary number to justify.
+ No hand-picked weight exists (ADR 0200 point 1): without an explicit
+ ``weights`` argument the adapter calls Cormack et al.'s (2009)
+ parameter-free reciprocal rank fusion. The paper's own finding is
+ that the unweighted form outperforms trained alternatives, so there
+ is no arbitrary number to justify.
Callers holding a psychometrically estimated set may still pass
it explicitly; the disclosed per-channel evidence carries
whichever weights actually fused.
@@ -372,9 +400,16 @@ def fuse_rankings(
raise RankWeaveNotAvailable(
"rankweave_not_available: ranking transport failed"
) from exc
- return project_ranking_list(
- raw, titles_by_id, channels=channels, weights=active_weights
- )
+ try:
+ return project_ranking_list(
+ raw, titles_by_id, channels=channels, weights=active_weights
+ )
+ except RankWeaveNotAvailable:
+ raise
+ except Exception as exc:
+ raise RankWeaveNotAvailable(
+ "rankweave_not_available: ranking projection failed"
+ ) from exc
def as_api_payload(
self,
diff --git a/pyproject.toml b/pyproject.toml
index 091ac2ce6..110858e16 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,7 +13,7 @@ dependencies = [
"pillow>=12.3.0",
# RankWeave has no PyPI release yet; pinned to a specific commit (not a
# floating branch ref) for reproducible installs, per org convention.
- "rankweave @ git+https://github.com/ContextualWisdomLab/RankWeave.git@61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6",
+ "rankweave @ git+https://github.com/ContextualWisdomLab/RankWeave.git@f92c3a2c7c50f3b5f4499dfdc94987d4e023a4fa",
# Explicit CA bundle for http_client HTTPS posts -- some interpreter
# distributions don't reliably inherit the OS trust store.
"certifi>=2024.0.0",
diff --git a/tests/test_rankweave_client.py b/tests/test_rankweave_client.py
index f3598ca91..3d05c76ba 100644
--- a/tests/test_rankweave_client.py
+++ b/tests/test_rankweave_client.py
@@ -1,9 +1,8 @@
"""Fail-closed RankWeave ranking port.
-RankWeave is an in-process weighted-RRF library. LineageWeave fuses
-only visible posts. A hidden post is omitted from every channel. The
-client never invents a fused score or a theta. Channel evidence is
-computed from owned rank lists (Cormack 2009), not RankWeave extras.
+RankWeave owns classic and convex-weighted RRF calculation. LineageWeave sends
+only visible posts and projects the owner's contributions from owned channel
+inputs. The client never invents a fused score or a theta.
"""
from __future__ import annotations
@@ -170,6 +169,18 @@ def fake_transport(
assert "fused_score" not in serialized
+def test_library_transport_uses_classic_rrf_without_convex_weights() -> None:
+ payload = build_rankweave_client().as_api_payload(
+ [PUBLIC, QUOTE],
+ can_see_post=lambda _row: True,
+ )
+
+ assert payload["status"] == "accepted"
+ assert payload["rankings"][0]["channel_evidence"] == _lexical_then_temporal(
+ "post-2", 1
+ )
+
+
def test_library_transport_projects_monkeypatched_rrf(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -177,19 +188,51 @@ def test_library_transport_projects_monkeypatched_rrf(
class FakeRw:
@staticmethod
- def weighted_reciprocal_rank_fuse(
+ def reciprocal_rank_fuse(
channels: dict[str, list[str]],
- weights: dict[str, float],
limit: int = 20,
rank_constant_eta: int = 60,
) -> list:
captured["channels"] = channels
- captured["weights"] = weights
captured["limit"] = limit
captured["eta"] = rank_constant_eta
return [
- SimpleNamespace(item_id="post-2", fused_score=0.99, theta=1.2),
- SimpleNamespace(item_id="post-1"),
+ SimpleNamespace(
+ item_id="post-2",
+ fused_score=0.99,
+ theta=1.2,
+ channel_contributions=(
+ SimpleNamespace(
+ channel_name="lexical",
+ rank=1,
+ weight=1.0,
+ contribution=1.0 / 61,
+ ),
+ SimpleNamespace(
+ channel_name="temporal",
+ rank=1,
+ weight=1.0,
+ contribution=1.0 / 61,
+ ),
+ ),
+ ),
+ SimpleNamespace(
+ item_id="post-1",
+ channel_contributions=(
+ SimpleNamespace(
+ channel_name="lexical",
+ rank=2,
+ weight=1.0,
+ contribution=1.0 / 62,
+ ),
+ SimpleNamespace(
+ channel_name="temporal",
+ rank=2,
+ weight=1.0,
+ contribution=1.0 / 62,
+ ),
+ ),
+ ),
]
monkeypatch.setattr(
@@ -201,7 +244,6 @@ def weighted_reciprocal_rank_fuse(
)
assert captured["eta"] == 60
- assert set(captured["weights"].values()) == {1.0}
assert payload["rankings"][0]["post_title"] == (
"Pricing renegotiation: revised quote sent"
)
diff --git a/uv.lock b/uv.lock
index 9ca8e35cc..2225d1d16 100644
--- a/uv.lock
+++ b/uv.lock
@@ -667,7 +667,7 @@ requires-dist = [
{ name = "pyjwt", extras = ["crypto"], marker = "extra == 'dev'", specifier = ">=2.8.0" },
{ name = "pyshacl", marker = "extra == 'dev'", specifier = ">=0.26.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
- { name = "rankweave", git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6" },
+ { name = "rankweave", git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=f92c3a2c7c50f3b5f4499dfdc94987d4e023a4fa" },
{ name = "rdflib", specifier = ">=7.0.0" },
{ name = "redis", marker = "extra == 'backend'", specifier = ">=5.0.0" },
{ name = "threadweave", specifier = ">=0.1.0" },
@@ -1218,7 +1218,7 @@ wheels = [
[[package]]
name = "rankweave"
version = "0.18.0"
-source = { git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6#61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6" }
+source = { git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=f92c3a2c7c50f3b5f4499dfdc94987d4e023a4fa#f92c3a2c7c50f3b5f4499dfdc94987d4e023a4fa" }
[[package]]
name = "rdflib"
From 66ba68859830891e5cee3ee3e7cc4a5b33bc58b3 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 17:16:13 -0700
Subject: [PATCH 061/393] fix(rankings): reuse trusted owner fusion envelope
(#674)
* refactor(rankings): consume RankWeave-owned RRF evidence
* fix(rankings): reject explicit empty weight evidence
* fix(rankings): reuse trusted owner fusion envelope
* fix(rankings): reject empty weights before transport
---------
Co-authored-by: Codex
---
lineageweave/rankweave_client.py | 82 ++++++++++++++++++++++----------
tests/test_rankweave_client.py | 36 +++++++++++---
2 files changed, 88 insertions(+), 30 deletions(-)
diff --git a/lineageweave/rankweave_client.py b/lineageweave/rankweave_client.py
index fde71810a..1a9b6f34a 100644
--- a/lineageweave/rankweave_client.py
+++ b/lineageweave/rankweave_client.py
@@ -42,10 +42,14 @@ class RankWeaveNotAvailable(RuntimeError):
reason = "rankweave_not_available"
+class _ClassicWeights(dict[str, float]):
+ """Mark caller-omitted weights selecting classic Cormack RRF."""
+
+
def _no_transport(
_channels: dict[str, list[str]],
_weights: dict[str, float],
-) -> list[dict[str, Any]]:
+) -> object:
raise RankWeaveNotAvailable(
"rankweave_not_available: RankWeave ranking port is not configured. "
"Pass RANKWEAVE_DISABLED=0 (default) or a transport= callable. "
@@ -173,7 +177,12 @@ def _owner_channel_evidence(
"""Index one RankWeave owner calculation by item identifier."""
return {
item_id: _evidence_from_owner_hit(hit)
- for hit in _owner_rrf_hits(channels, weights, eta)
+ for hit in _owner_rrf_hits(
+ channels,
+ weights,
+ eta,
+ classic=isinstance(weights, _ClassicWeights),
+ )
if (item_id := _item_id_from_hit(hit))
}
@@ -232,17 +241,25 @@ def to_json(self) -> list[dict[str, Any]]:
return [item.to_json() for item in self.items]
+@dataclass(frozen=True)
+class _OwnerRankingEnvelope:
+ """RankWeave hits produced by the trusted in-process adapter."""
+
+ hits: tuple[object, ...]
+
+
def _owner_rrf_hits(
channels: Mapping[str, Sequence[str]],
weights: Mapping[str, float],
eta: int,
*,
limit: int | None = None,
+ classic: bool = False,
) -> list[object]:
"""Return RankWeave-owned classic or convex-weighted RRF results."""
try:
rw = _import_rankweave()
- if all(float(weights.get(name) or 0.0) == 1.0 for name in channels):
+ if classic:
return list(
rw.reciprocal_rank_fuse(
channels,
@@ -287,22 +304,34 @@ def project_ranking_list(
owns. Transport extra fields are ignored so RankWeave cannot invent
a missing signal.
"""
- if not isinstance(raw, list):
+ if isinstance(raw, _OwnerRankingEnvelope):
+ raw_hits = list(raw.hits)
+ evidence_by_post_id = {
+ item_id: _evidence_from_owner_hit(hit)
+ for hit in raw_hits
+ if (item_id := _item_id_from_hit(hit))
+ }
+ elif isinstance(raw, list):
+ raw_hits = raw
+ if not raw_hits:
+ return RankingList(items=())
+ owned_channels = channels or {}
+ evidence_by_post_id = _owner_channel_evidence(
+ owned_channels,
+ (
+ weights
+ if weights is not None
+ else _ClassicWeights({name: 1.0 for name in owned_channels})
+ ),
+ DEFAULT_RANK_CONSTANT_ETA,
+ )
+ else:
raise RankWeaveNotAvailable(
"rankweave_not_available: ranking envelope is not a hit list"
)
items: list[RankedPost] = []
seen: set[str] = set()
- owned_channels = channels or {}
- # Parameter-free classic RRF default (ADR 0200 point 1): every
- # channel weighs 1.0 unless the caller passes an estimated set.
- owned_weights = weights or {name: 1.0 for name in owned_channels}
- evidence_by_post_id = _owner_channel_evidence(
- owned_channels,
- owned_weights,
- DEFAULT_RANK_CONSTANT_ETA,
- )
- for hit in raw:
+ for hit in raw_hits:
post_id = _item_id_from_hit(hit)
title = str(titles_by_id.get(post_id) or "").strip()
if not post_id or not title or post_id in seen:
@@ -326,14 +355,15 @@ def __call__(
self,
channels: dict[str, list[str]],
weights: dict[str, float],
- ) -> list[dict[str, Any]]:
+ ) -> object:
+ classic = isinstance(weights, _ClassicWeights)
usable = {
name: [item_id for item_id in ranks if str(item_id).strip()]
for name, ranks in channels.items()
if ranks
}
if not usable:
- return []
+ return _OwnerRankingEnvelope(hits=())
active_weights = {
name: weights[name] for name in usable if name in weights and weights[name] > 0
}
@@ -347,13 +377,9 @@ def __call__(
active_weights,
DEFAULT_RANK_CONSTANT_ETA,
limit=DEFAULT_RANKING_LIMIT,
+ classic=classic,
)
- projected: list[dict[str, Any]] = []
- for hit in hits:
- item_id = _item_id_from_hit(hit)
- if item_id:
- projected.append({"item_id": item_id})
- return projected
+ return _OwnerRankingEnvelope(hits=tuple(hits))
def build_rankweave_client(disabled: bool = False) -> "RankWeaveClient":
@@ -369,7 +395,7 @@ class RankWeaveClient:
def __init__(
self,
transport: Callable[
- [dict[str, list[str]], dict[str, float]], list[dict[str, Any]]
+ [dict[str, list[str]], dict[str, float]], object
] = _no_transport,
) -> None:
self._transport = transport
@@ -391,7 +417,15 @@ def fuse_rankings(
it explicitly; the disclosed per-channel evidence carries
whichever weights actually fused.
"""
- active_weights = weights or {name: 1.0 for name in channels}
+ if weights is not None and not weights:
+ raise RankWeaveNotAvailable(
+ "rankweave_not_available: explicit channel weights are empty"
+ )
+ active_weights = (
+ weights
+ if weights is not None
+ else _ClassicWeights({name: 1.0 for name in channels})
+ )
try:
raw = self._transport(channels, active_weights)
except RankWeaveNotAvailable:
diff --git a/tests/test_rankweave_client.py b/tests/test_rankweave_client.py
index 3d05c76ba..a223615dc 100644
--- a/tests/test_rankweave_client.py
+++ b/tests/test_rankweave_client.py
@@ -181,6 +181,18 @@ def test_library_transport_uses_classic_rrf_without_convex_weights() -> None:
)
+def test_explicit_empty_weight_vector_fails_before_transport() -> None:
+ def transport(*_args: object) -> object:
+ pytest.fail("invalid explicit weights must not cross the transport boundary")
+
+ with pytest.raises(RankWeaveNotAvailable, match="rankweave_not_available"):
+ RankWeaveClient(transport=transport).fuse_rankings(
+ {"temporal": ["post-1"], "lexical": ["post-1"]},
+ {"post-1": "Public post"},
+ weights={},
+ )
+
+
def test_library_transport_projects_monkeypatched_rrf(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -193,6 +205,7 @@ def reciprocal_rank_fuse(
limit: int = 20,
rank_constant_eta: int = 60,
) -> list:
+ captured["calls"] = int(captured.get("calls", 0)) + 1
captured["channels"] = channels
captured["limit"] = limit
captured["eta"] = rank_constant_eta
@@ -244,6 +257,7 @@ def reciprocal_rank_fuse(
)
assert captured["eta"] == 60
+ assert captured["calls"] == 1
assert payload["rankings"][0]["post_title"] == (
"Pricing renegotiation: revised quote sent"
)
@@ -282,6 +296,16 @@ def test_unknown_envelope_fails_closed() -> None:
project_ranking_list({"hits": [{"item_id": "spoofed"}]}, {"spoofed": "x"})
+def test_empty_transport_result_does_not_start_an_owner_calculation(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ "lineageweave.rankweave_client._import_rankweave",
+ lambda: pytest.fail("empty projection must not call RankWeave"),
+ )
+ assert project_ranking_list([], {}).items == ()
+
+
def test_unknown_hit_id_is_dropped_not_repaired() -> None:
ranking = project_ranking_list(
[{"item_id": "invented"}, {"item_id": "post-2"}],
@@ -297,12 +321,12 @@ def test_ranking_channel_evidence_uses_cormack_weighted_rrf() -> None:
evidence = ranking_channel_evidence(
"post-1",
{"temporal": ["post-1"], "lexical": ["post-1"]},
- {"temporal": 1.0, "lexical": 1.0},
+ {"temporal": 0.5, "lexical": 0.5},
eta=60,
)
by_code = {item.signal_code: item for item in evidence}
- assert by_code["lexical"].contribution == 1.0 / 61
- assert by_code["temporal"].contribution == 1.0 / 61
+ assert by_code["lexical"].contribution == 0.5 / 61
+ assert by_code["temporal"].contribution == 0.5 / 61
assert by_code["lexical"].channel_rank == 1
assert by_code["temporal"].channel_rank == 1
assert by_code["lexical"].rank == 1
@@ -342,7 +366,7 @@ def test_project_ranking_list_ignores_transport_extra_fields() -> None:
],
{"post-1": "Public post"},
channels={"temporal": ["post-1"], "lexical": ["post-2"]},
- weights={"temporal": 1.0, "lexical": 1.0},
+ weights={"temporal": 0.5, "lexical": 0.5},
)
payload = ranking.to_json()
assert payload[0]["channel_evidence"] == [
@@ -350,8 +374,8 @@ def test_project_ranking_list_ignores_transport_extra_fields() -> None:
"signal_code": "temporal",
"signal_label": "Newest first",
"channel_rank": 1,
- "weight": 1.0,
- "contribution": 1.0 / 61,
+ "weight": 0.5,
+ "contribution": 0.5 / 61,
"rank": 1,
}
]
From 674f28051fffcd765f992ba373646f32d5317372 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 09:17:11 +0900
Subject: [PATCH 062/393] test(auth): refresh tokens for long suites
---
backend/tests/test_api.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index f7127ab4a..21f3f327d 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -273,8 +273,9 @@ def _fetch_demo_analyst_token() -> str:
return token_response["access_token"]
-@pytest.fixture(scope="module")
+@pytest.fixture
def demo_analyst_token() -> str:
+ """Return a fresh token so long-running suites cannot outlive its TTL."""
return _fetch_demo_analyst_token()
From 0f0cf4a80c6acb130e8176f31618ccc2f14dc947 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 09:27:51 +0900
Subject: [PATCH 063/393] ops(compose): pin canonical project lifecycle
---
AGENTS.md | 14 +++++++
docker-compose.yml | 2 +
.../0224-canonical-compose-project-name.md | 39 +++++++++++++++++++
docs/adr/README.md | 1 +
tests/test_frontend_container_contract.py | 7 ++++
5 files changed, 63 insertions(+)
create mode 100644 docs/adr/0224-canonical-compose-project-name.md
diff --git a/AGENTS.md b/AGENTS.md
index 0b9e33828..8de0e0aca 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -237,6 +237,20 @@ vector degrades that pair back to difflib; it never fabricates a score.
## Tests
+### Isolated Compose lifecycle
+
+- The canonical standalone Compose project is `lineageweave` (ADR 0224).
+- A test, review, or stacked-PR environment may use an explicit isolated
+ project name only while that environment is needed. Once its stated test or
+ review objective has succeeded, preserve the relevant evidence and port any
+ required behavior into the canonical Compose contract, then run `docker
+ compose -p down` so its containers and network do not become
+ a second production-looking stack.
+- Never use `down -v` or otherwise delete named volumes without separate,
+ explicit authorization. Resolve the exact project from Compose labels before
+ cleanup; never target a glob, directory root, or another agent's active
+ environment.
+
```bash
# backend extra compiles fast-mlsirm's PyO3 core -- needs rustc 1.97.1
# (see backend/Dockerfile). Without it, pip falls over at build time.
diff --git a/docker-compose.yml b/docker-compose.yml
index e2990df32..924bd3092 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,3 +1,5 @@
+name: lineageweave
+
services:
postgres:
# Built (not bind-mounted) so the keycloak-db init script and the
diff --git a/docs/adr/0224-canonical-compose-project-name.md b/docs/adr/0224-canonical-compose-project-name.md
new file mode 100644
index 000000000..c906e2124
--- /dev/null
+++ b/docs/adr/0224-canonical-compose-project-name.md
@@ -0,0 +1,39 @@
+# ADR 0224: Pin the canonical Compose project name
+
+## Status
+
+Accepted — 2026-08-26
+
+## Context
+
+Docker Compose otherwise derives its project name from the checkout directory.
+Stacked PR worktrees therefore produced several production-looking
+`lineageweave-*` projects with duplicated networks and stateful services. That
+made the product preview ambiguous and could point operators at the wrong
+PostgreSQL or identity service.
+
+## Decision
+
+The repository Compose file declares `name: lineageweave`. Normal `docker
+compose` commands therefore converge on one canonical standalone stack,
+regardless of the checkout directory name.
+
+An isolated test or review environment may override the name explicitly with
+`docker compose -p `. Such an override is test infrastructure,
+not another canonical deployment. After its declared objective succeeds, its
+evidence and any required behavior are retained, then its containers and
+network are removed with `docker compose -p down`. Named
+volumes are never deleted as part of project-name consolidation without a
+separate, explicit authorization.
+
+The canonical standalone stack retains its synthetic local Keycloak fallback.
+Organization-integrated deployments configure the central Keyverse issuer as
+required by ADR 0028 and ADR 0156; the Compose project name does not change the
+identity-provider trust boundary.
+
+## Consequences
+
+- `docker compose up` consistently creates the `lineageweave` project.
+- Preview and operational instructions have one unambiguous project name.
+- Parallel review stacks must opt into a distinct name and ports deliberately.
+- Existing noncanonical volumes remain recoverable until separately retired.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index eadef2874..492097d78 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -22,6 +22,7 @@ decision from them.
| Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) |
| [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) |
| [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) |
+| Canonical Docker Compose project (`name: lineageweave`) | [0224](0224-canonical-compose-project-name.md) |
[0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/).
diff --git a/tests/test_frontend_container_contract.py b/tests/test_frontend_container_contract.py
index 8970f34f7..546198405 100644
--- a/tests/test_frontend_container_contract.py
+++ b/tests/test_frontend_container_contract.py
@@ -6,6 +6,13 @@
_ROOT = Path(__file__).resolve().parents[1]
+def test_compose_uses_the_canonical_project_name() -> None:
+ """Directory names must not create duplicate production-like stacks."""
+ compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
+
+ assert compose.startswith("name: lineageweave\n")
+
+
def test_compose_and_frontend_image_share_vite_build_arguments() -> None:
"""Compose overrides must reach the names Vite reads during its build."""
compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
From 72ef6dd6fde7863340af925ba068acd4a9138185 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 09:39:29 +0900
Subject: [PATCH 064/393] fix(rankings): keep legacy transport evidence
unavailable
---
lineageweave/rankweave_client.py | 18 +++++-------------
tests/test_rankweave_client.py | 23 ++++++++++-------------
2 files changed, 15 insertions(+), 26 deletions(-)
diff --git a/lineageweave/rankweave_client.py b/lineageweave/rankweave_client.py
index 1a9b6f34a..2abc612a0 100644
--- a/lineageweave/rankweave_client.py
+++ b/lineageweave/rankweave_client.py
@@ -300,9 +300,10 @@ def project_ranking_list(
) -> RankingList:
"""Accept transport output. Unknown shapes fail closed. Hidden ids drop.
- Channel evidence is attached from ``channels`` LineageWeave already
- owns. Transport extra fields are ignored so RankWeave cannot invent
- a missing signal.
+ Channel evidence is accepted only from the trusted in-process owner
+ envelope. Legacy list transports retain their ordering but expose an
+ empty breakdown; re-fusing their inputs could diverge from that ordering.
+ Transport extra fields are ignored so a transport cannot invent a signal.
"""
if isinstance(raw, _OwnerRankingEnvelope):
raw_hits = list(raw.hits)
@@ -315,16 +316,7 @@ def project_ranking_list(
raw_hits = raw
if not raw_hits:
return RankingList(items=())
- owned_channels = channels or {}
- evidence_by_post_id = _owner_channel_evidence(
- owned_channels,
- (
- weights
- if weights is not None
- else _ClassicWeights({name: 1.0 for name in owned_channels})
- ),
- DEFAULT_RANK_CONSTANT_ETA,
- )
+ evidence_by_post_id = {}
else:
raise RankWeaveNotAvailable(
"rankweave_not_available: ranking envelope is not a hit list"
diff --git a/tests/test_rankweave_client.py b/tests/test_rankweave_client.py
index a223615dc..c79dba809 100644
--- a/tests/test_rankweave_client.py
+++ b/tests/test_rankweave_client.py
@@ -155,13 +155,13 @@ def fake_transport(
"post_id": "post-2",
"post_title": "Pricing renegotiation: revised quote sent",
"fused_rank": 1,
- "channel_evidence": _lexical_then_temporal("post-2", 1),
+ "channel_evidence": [],
},
{
"post_id": "post-1",
"post_title": "Public post",
"fused_rank": 2,
- "channel_evidence": _lexical_then_temporal("post-1", 2),
+ "channel_evidence": [],
},
]
serialized = json.dumps(payload)
@@ -355,7 +355,13 @@ def test_ranking_channel_evidence_tie_breaks_by_signal_code() -> None:
assert evidence[0].contribution == evidence[1].contribution == 0.5 / 61
-def test_project_ranking_list_ignores_transport_extra_fields() -> None:
+def test_project_ranking_list_does_not_refuse_legacy_transport_for_evidence(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ "lineageweave.rankweave_client._owner_channel_evidence",
+ lambda *_args, **_kwargs: pytest.fail("legacy ordering must not be re-fused"),
+ )
ranking = project_ranking_list(
[
{
@@ -369,16 +375,7 @@ def test_project_ranking_list_ignores_transport_extra_fields() -> None:
weights={"temporal": 0.5, "lexical": 0.5},
)
payload = ranking.to_json()
- assert payload[0]["channel_evidence"] == [
- {
- "signal_code": "temporal",
- "signal_label": "Newest first",
- "channel_rank": 1,
- "weight": 0.5,
- "contribution": 0.5 / 61,
- "rank": 1,
- }
- ]
+ assert payload[0]["channel_evidence"] == []
serialized = json.dumps(payload)
assert "theta" not in serialized
assert "invented" not in serialized
From c6d882fe9c83ad5460c4c02b03d32f97c56a3291 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 09:42:48 +0900
Subject: [PATCH 065/393] fix(dashboard): keep implementation details out of
customer copy
---
AGENTS.md | 8 +++++
.../adr/0206-evidence-operations-dashboard.md | 15 ++++++---
docs/product-technical-gap-baseline.md | 8 ++---
.../components/OperationsDashboard.test.tsx | 12 ++++---
.../src/components/OperationsDashboard.tsx | 31 +++++++------------
5 files changed, 43 insertions(+), 31 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 8de0e0aca..1486c6ccf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,6 +21,14 @@ documents unless an ADR explicitly promotes a decision from them --
to its governing ADR. Update research notes as literature changes; never
use them to introduce an untracked architecture decision.
+Customer-facing copy must help the reader take the next product action. Do
+not expose implementation boundaries, provider or package names, schema
+versions, internal status/reason codes, environment variables, transport
+setup, hashes, or developer remediation instructions as explanatory UI copy.
+Keep that evidence in governed audit/admin surfaces and logs; translate a
+customer-visible state into the source, decision, retry, or administrator
+action the reader can actually take.
+
## Hard rule: no real data in repository artifacts
This repository ships **synthetic fixtures only** (`lineageweave/fixtures.py`)
diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
index a1235c232..5e48ba6c2 100644
--- a/docs/adr/0206-evidence-operations-dashboard.md
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -78,10 +78,17 @@ provenance.
a fact cannot be both. Missing facts carry no invented value or evidence
span and inherit the analysis run and authorized-source boundary through
their classification parent.
-9. Project journeys group events only by an explicit source project or stored
- semantic project mention. A multi-project post may appear in multiple
- journeys. Unbound events remain visible as unassigned evidence and are not
- attached to the nearest project.
+9. Project membership uses only an explicit source project or stored semantic
+ project mention. A multi-project post may appear in multiple groups;
+ unbound events remain unassigned. A chronological sort of those records is
+ only a **project-observed-event list**, not a Project Journey. Project
+ Journey starts, predecessors, branches, and transitions consume a
+ provenance-bearing TEPP TDT/CHRONOS result. Previous projects, customer
+ requests, procurement notices, negotiated/direct bidding, external
+ sensing, internal discussions, and sales leads are all admissible starts or
+ predecessors when the accepted TEPP artifact and source evidence connect
+ them. LineageWeave never chooses a fixed first stage or promotes nearest-date
+ ordering to a lineage edge.
10. A repeat-issue result carries both the issue-pattern evidence and any
source-supported improvement action. Its Dashboard flow is As-Is evidence
to To-Be action: rebid history retrieval, originating-order/specification
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 3ac41f4a0..00008e677 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -12,7 +12,7 @@
| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification and `claim_received` → `cause_confirmed` milestones with cited spans and observed source clocks | Stacked candidate reports open/resolved/evidence-missing counts and exact elapsed time only for paired observed endpoints; every required answer and endpoint is cited or explicitly missing; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending |
| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending |
| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated synthetic runtime passed with the honest zero-result state |
-| Project-specific journey | Explicit source/semantic project membership plus event-time ordering | Candidate API preserves every explicit project membership and the UI orders each journey chronologically; authenticated synthetic runtime passed |
+| Project-specific journey | Explicit source/semantic project membership plus provenance-bearing TEPP TDT/CHRONOS predecessor, branch, and transition results | Candidate API preserves every explicit project membership, but its local event-time sort is only an observed-event list. It is no longer labeled as a Project Journey. Full journey delivery remains open until the accepted TEPP producer artifact is persisted and rendered; no fixed sales/order start or nearest-date edge is accepted. |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending |
| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending |
@@ -50,9 +50,9 @@ snapshot.
The `f0b96029` Storybook build was rendered at 1440×1100 and 402×1200 with
synthetic evidence; `416fd19d` changes only post-navigation request isolation.
Desktop inspection showed all four case kinds, five non-conflated metrics,
-project-journey ordering, cited facts, and evidence actions without horizontal
+project-observed-event ordering, cited facts, and evidence actions without horizontal
card overflow. Narrow inspection showed two-column metrics, readable cards and
-44px-class actions; the project journey remains intentionally horizontally
+44px-class actions; the project event list remains intentionally horizontally
scrollable. No identifying runtime record or screenshot is committed. The
`EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`,
`AnalysisFailed`, and `LoadError` scenes cover the ADR 0206 state inventory.
@@ -82,7 +82,7 @@ At the repaired dashboard head, the `EvidenceReady` and `NarrowViewport`
stories were re-rendered locally with synthetic data at desktop and iPhone
13 viewports. The desktop shows separate Event/post values and evidence
actions; the narrow view preserves readable cards and 44px-class actions while
-keeping the multi-step project journey horizontally scrollable. These images
+keeping the multi-step project event list horizontally scrollable. These images
remain local audit evidence and are not committed.
An authenticated synthetic OIDC audit then found that the mobile breakpoint
hid the entire GNB despite having no drawer implementation. Candidate
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index c61954710..08dc61138 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -113,11 +113,13 @@ describe("OperationsDashboardView", () => {
expect(screen.getByText("업무 관계 · 프로젝트")).toBeInTheDocument();
});
- it("places multi-project evidence in every explicit journey and orders events oldest first", () => {
+ it("places multi-project evidence in every observed-event group without calling it a journey", () => {
const later = { ...data.cases[0], post_id: "post-later", occurred_at: "2026-08-20T00:00:00Z", project_names: ["Synthetic Grid Upgrade", "Synthetic Relay Renewal"] };
const earlier = { ...data.cases[0], post_id: "post-earlier", occurred_at: "2026-08-01T00:00:00Z", project_names: ["Synthetic Grid Upgrade"] };
render( undefined} />);
+ expect(screen.getByRole("heading", { name: "프로젝트별 관측 Event" })).toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "프로젝트 여정" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Synthetic Relay Renewal" })).toBeInTheDocument();
const primaryJourney = screen.getByRole("heading", { name: "Synthetic Grid Upgrade" }).parentElement;
expect(primaryJourney?.querySelectorAll("time")[0]).toHaveAttribute("datetime", earlier.occurred_at);
@@ -133,8 +135,9 @@ describe("OperationsDashboardView", () => {
it("keeps unavailable topic measurement actionable without a fallback score", () => {
render( undefined} />);
- expect(screen.getByText("Topic model influence를 아직 표시할 수 없습니다.")).toBeInTheDocument();
- expect(screen.getByText("TEPP posterior topic 계약 결과를 먼저 완료하세요.")).toBeInTheDocument();
+ expect(screen.getByText("글 영향도를 아직 확인할 수 없습니다.")).toBeInTheDocument();
+ expect(screen.getByText("분석 대상 글의 사건 시점과 조직 소속을 확인한 뒤 다시 분석하세요.")).toBeInTheDocument();
+ expect(screen.queryByText(/TEPP|fast-mlsirm|topic_context_posterior/)).not.toBeInTheDocument();
expect(screen.queryByText(/추정 점수/)).not.toBeInTheDocument();
});
@@ -169,7 +172,8 @@ describe("OperationsDashboardView", () => {
render( );
expect(screen.getAllByText("4.25")).toHaveLength(2);
expect(screen.getByText((_, element) => element?.tagName === "LI" && element.textContent === "2026-08-01 · birth")).toBeInTheDocument();
- expect(screen.getByText("tepp-snapshot")).toBeInTheDocument();
+ expect(screen.getByText("분석 기준 확인")).toBeInTheDocument();
+ expect(screen.queryByText(/tepp-snapshot|fast-mlsirm|rust_gpu/)).not.toBeInTheDocument();
await userEvent.click(screen.getAllByRole("button", { name: "근거 글 열기" })[1]);
expect(onOpenPost).toHaveBeenCalledWith("post-2");
});
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index fc26540d4..e86529a98 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -72,7 +72,7 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP
/** Renders a completed Dashboard response for runtime and Storybook scenes. */
export function OperationsDashboardView({ data, externalOnly = false, onOpenPost }: { data: OperationsDashboardResponse; externalOnly?: boolean; onOpenPost: (postId: string) => void }) {
const cases = externalOnly ? data.cases.filter((item) => item.case_kind_code === "external_information") : data.cases;
- const journeys = Object.entries(
+ const observedProjectEvents = Object.entries(
cases.reduce>((groups, item) => {
const projects = item.project_names ?? (item.project_name ? [item.project_name] : []);
projects.forEach((project) => (groups[project] ??= []).push(item));
@@ -122,10 +122,10 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{!externalOnly ? (
) : null}
- {!externalOnly && journeys.length ? (
-
- 프로젝트 여정
- {journeys.map(([project, events]) => (
+ {!externalOnly && observedProjectEvents.length ? (
+
+ 프로젝트별 관측 Event
+ {observedProjectEvents.map(([project, events]) => (
{project}
@@ -193,20 +193,17 @@ export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDa
return (
{topicContext.status_code === "unavailable" ? (
-
Topic model influence를 아직 표시할 수 없습니다.
-
{topicContext.next_action}
-
{topicContext.required_contracts.map((contract) => (
- {contract.authority} · {contract.schema_version} · {contract.state_code === "persisted" ? "저장 완료" : "승인 결과 없음"}
- ))}
+
글 영향도를 아직 확인할 수 없습니다.
+
분석 대상 글의 사건 시점과 조직 소속을 확인한 뒤 다시 분석하세요.
) : (
<>
- {topicContext.next_action}
+ 각 글의 영향도와 불확실성을 비교하고 원문 근거를 확인하세요.
{topicContext.topics.map((topic) => (
@@ -247,14 +244,10 @@ export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDa
{topicContext.model_run ? (
- 모형·실행 근거
+ 분석 기준 확인
-
TEPP run {topicContext.model_run.tepp_run_id}
-
TEPP snapshot {topicContext.model_run.tepp_snapshot_id}
-
Snapshot {topicContext.model_run.source_snapshot_sha256}
-
Knowledge cutoff {topicContext.model_run.knowledge_cutoff}
-
Posterior draws {topicContext.model_run.posterior_draw_count} · {topicContext.model_run.posterior_draw_set_id}
-
fast-mlsirm {topicContext.model_run.fast_mlsirm_version} · {topicContext.model_run.compute_backend_code} · {topicContext.model_run.precision_code}
+
반영 기준 시각 {topicContext.model_run.knowledge_cutoff}
+
Topic 수 {topicContext.model_run.topic_count}
) : null}
From 9698186560dd6bbb4c6b3694097ef186240c10a8 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 09:46:40 +0900
Subject: [PATCH 066/393] fix(ui): translate internal ranking states into
customer actions
---
frontend/src/App.test.tsx | 10 ++++----
frontend/src/App.tsx | 12 +++++-----
frontend/src/i18n.test.ts | 7 +++++-
frontend/src/i18n.ts | 48 +++++++++++++++++++++++----------------
4 files changed, 46 insertions(+), 31 deletions(-)
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index fa941721d..414cadd5e 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -3088,11 +3088,13 @@ describe("App, authenticated", () => {
);
});
- it("names RankWeave unavailability on home rankings instead of inventing a fused score", async () => {
+ it("gives a customer action when ranking evidence is unavailable", async () => {
stubBackend();
render( );
- expect(await screen.findByText("Rankings · RankWeave not available")).toBeInTheDocument();
+ expect(await screen.findByText("Rankings are not ready. Refresh after the source evidence is connected.")).toBeInTheDocument();
+ expect(screen.getByText("Evidence needed")).toBeInTheDocument();
+ expect(screen.queryByText(/RankWeave|rankweave_not_available/)).not.toBeInTheDocument();
expect(screen.queryByText("Pricing renegotiation: revised quote sent")).not.toBeInTheDocument();
});
@@ -3196,11 +3198,11 @@ describe("App, authenticated", () => {
name: /open ranking: public post/i,
});
expect(rankingButton).toHaveTextContent("Public post");
- expect(rankingButton).toHaveTextContent("Rankings · rankweave");
+ expect(screen.getByText("Evidence combined")).toBeInTheDocument();
expect(rankingButton).toHaveTextContent("rank 1");
expect(
screen.getByText(
- "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.",
+ "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.",
),
).toBeInTheDocument();
expect(
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b0dc2ff79..179c92952 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -3351,7 +3351,7 @@ function RankingsPanel({
setError(null);
fetchRankings(accessToken)
.then(setRanking)
- .catch((err) => setError(String(err)));
+ .catch(() => setError(t("Rankings could not be loaded. Retry in a moment.")));
}, [accessToken]);
return (
@@ -3361,24 +3361,24 @@ function RankingsPanel({
{ranking && (
{ranking.status === "accepted"
- ? "rankweave"
- : `rankweave · ${ranking.status_reason ?? "unavailable"}`}
+ ? t("Evidence combined")
+ : t("Evidence needed")}
)}
{error && {error}
}
{ranking === null && !error && {t("Loading rankings...")}
}
{ranking && ranking.status === "unavailable" && (
- {t("Rankings · RankWeave not available")}
+ {t("Rankings are not ready. Refresh after the source evidence is connected.")}
)}
{ranking && ranking.status === "accepted" && ranking.rankings.length === 0 && (
- {t("No fused rankings from RankWeave.")}
+ {t("No comparable posts are visible. Check the period and access scope.")}
)}
{ranking && ranking.rankings.length > 0 && (
<>
{t(
- "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.",
+ "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.",
)}
diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts
index b647a585b..f9bcabf2f 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -73,7 +73,12 @@ describe("i18n", () => {
"This is an ontology neighborhood, not Event Lineage.",
"Rankings",
"Title overlap",
- "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.",
+ "Evidence combined",
+ "Evidence needed",
+ "Rankings could not be loaded. Retry in a moment.",
+ "Rankings are not ready. Refresh after the source evidence is connected.",
+ "No comparable posts are visible. Check the period and access scope.",
+ "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.",
"Workspace navigation",
"Observed calendar events",
"No observed calendar events are available.",
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 2d19b1cb8..9ed35f342 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -37,16 +37,18 @@ const TRANSLATIONS: Partial>> = {
"Log out": "로그아웃",
Calendar: "캘린더",
Rankings: "순위",
- "Rankings · RankWeave not available": "순위 · RankWeave를 사용할 수 없음",
- "Rankings · rankweave": "순위 · rankweave",
+ "Evidence combined": "근거 결합 완료",
+ "Evidence needed": "근거 확인 필요",
"Loading rankings...": "순위를 불러오는 중...",
- "No fused rankings from RankWeave.": "RankWeave가 융합한 순위가 없습니다.",
+ "Rankings could not be loaded. Retry in a moment.": "순위를 불러오지 못했습니다. 잠시 후 다시 시도하세요.",
+ "Rankings are not ready. Refresh after the source evidence is connected.": "순위를 확인할 수 없습니다. 원문 근거를 연결한 뒤 새로고침하세요.",
+ "No comparable posts are visible. Check the period and access scope.": "비교할 수 있는 글이 없습니다. 기간과 접근 범위를 확인하세요.",
"Fused rankings": "융합 순위",
"Open ranking: {title}": "순위 열기: {title}",
"rank {rank}": "순위 {rank}",
"Title overlap": "제목 겹침",
- "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.":
- "RankWeave가 최신순과 제목 겹침 순위를 융합했습니다. 보정된 점수가 아닙니다.",
+ "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.":
+ "최신성과 내용 연관성 근거를 비교하고 실행 전에 원문을 여세요. 이 순위는 성과 점수가 아닙니다.",
"Ranking evidence for {title}": "{title}의 순위 근거",
"{label} rank {rank}, contribution {contribution}":
"{label} 순위 {rank}, 기여 {contribution}",
@@ -542,16 +544,18 @@ const TRANSLATIONS: Partial>> = {
"Log out": "退出登录",
Calendar: "日历",
Rankings: "排名",
- "Rankings · RankWeave not available": "排名 · RankWeave 不可用",
- "Rankings · rankweave": "排名 · rankweave",
+ "Evidence combined": "证据已合并",
+ "Evidence needed": "需要核对证据",
"Loading rankings...": "正在加载排名...",
- "No fused rankings from RankWeave.": "RankWeave 未返回融合排名。",
+ "Rankings could not be loaded. Retry in a moment.": "无法加载排名,请稍后重试。",
+ "Rankings are not ready. Refresh after the source evidence is connected.": "排名尚未就绪。连接原始证据后请刷新。",
+ "No comparable posts are visible. Check the period and access scope.": "没有可比较的文章。请检查时间范围和访问权限。",
"Fused rankings": "融合排名",
"Open ranking: {title}": "打开排名:{title}",
"rank {rank}": "排名 {rank}",
"Title overlap": "标题重叠",
- "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.":
- "RankWeave 融合了最新优先和标题重叠排名。这不是校准分数。",
+ "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.":
+ "请比较时效性和内容相关证据,并在采取行动前打开原文。该排名不是绩效分数。",
"Ranking evidence for {title}": "{title} 的排名证据",
"{label} rank {rank}, contribution {contribution}":
"{label} 排名 {rank},贡献 {contribution}",
@@ -1063,16 +1067,18 @@ const TRANSLATIONS: Partial>> = {
"Log out": "ログアウト",
Calendar: "カレンダー",
Rankings: "ランキング",
- "Rankings · RankWeave not available": "ランキング · RankWeave を利用できません",
- "Rankings · rankweave": "ランキング · rankweave",
+ "Evidence combined": "根拠の結合完了",
+ "Evidence needed": "根拠の確認が必要",
"Loading rankings...": "ランキングを読み込み中...",
- "No fused rankings from RankWeave.": "RankWeave の融合ランキングはありません。",
+ "Rankings could not be loaded. Retry in a moment.": "ランキングを読み込めませんでした。しばらくしてから再試行してください。",
+ "Rankings are not ready. Refresh after the source evidence is connected.": "ランキングはまだ確認できません。原文の根拠を接続してから更新してください。",
+ "No comparable posts are visible. Check the period and access scope.": "比較できる投稿がありません。期間とアクセス範囲を確認してください。",
"Fused rankings": "融合ランキング",
"Open ranking: {title}": "ランキングを開く: {title}",
"rank {rank}": "順位 {rank}",
"Title overlap": "タイトル一致",
- "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.":
- "RankWeave が新しい順とタイトル一致の順位を融合しました。校正されたスコアではありません。",
+ "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.":
+ "新しさと内容関連性の根拠を比較し、対応前に原文を開いてください。この順位は業績スコアではありません。",
"Ranking evidence for {title}": "{title} の順位根拠",
"{label} rank {rank}, contribution {contribution}":
"{label} 順位 {rank}、寄与 {contribution}",
@@ -1563,16 +1569,18 @@ const TRANSLATIONS: Partial>> = {
"Log out": "Đăng xuất",
Calendar: "Lịch",
Rankings: "Xếp hạng",
- "Rankings · RankWeave not available": "Xếp hạng · RankWeave không khả dụng",
- "Rankings · rankweave": "Xếp hạng · rankweave",
+ "Evidence combined": "Đã kết hợp bằng chứng",
+ "Evidence needed": "Cần kiểm tra bằng chứng",
"Loading rankings...": "Đang tải xếp hạng...",
- "No fused rankings from RankWeave.": "Không có xếp hạng hợp nhất từ RankWeave.",
+ "Rankings could not be loaded. Retry in a moment.": "Không thể tải xếp hạng. Hãy thử lại sau giây lát.",
+ "Rankings are not ready. Refresh after the source evidence is connected.": "Xếp hạng chưa sẵn sàng. Hãy làm mới sau khi kết nối bằng chứng gốc.",
+ "No comparable posts are visible. Check the period and access scope.": "Không có bài viết nào có thể so sánh. Hãy kiểm tra khoảng thời gian và phạm vi truy cập.",
"Fused rankings": "Xếp hạng hợp nhất",
"Open ranking: {title}": "Mở xếp hạng: {title}",
"rank {rank}": "hạng {rank}",
"Title overlap": "Trùng tiêu đề",
- "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.":
- "RankWeave đã hợp nhất hạng mới nhất trước và trùng tiêu đề. Đây không phải điểm đã hiệu chỉnh.",
+ "Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.":
+ "Hãy so sánh bằng chứng về độ mới và mức liên quan nội dung, rồi mở bài gốc trước khi hành động. Xếp hạng này không phải điểm hiệu suất.",
"Ranking evidence for {title}": "Bằng chứng xếp hạng cho {title}",
"{label} rank {rank}, contribution {contribution}":
"{label} hạng {rank}, đóng góp {contribution}",
From 169f599fd8840bb8de8e38238a43c3bc32b9614f Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 09:54:10 +0900
Subject: [PATCH 067/393] test(auth): refresh cached token at issuer expiry
---
backend/tests/test_api.py | 42 +++++++++++++++++++++++++++++++++++++--
1 file changed, 40 insertions(+), 2 deletions(-)
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 21f3f327d..08f909f28 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -15,6 +15,7 @@
import asyncio
import math
import os
+import time
import uuid
from contextlib import closing
from pathlib import Path
@@ -37,6 +38,7 @@
_KEYCLOAK_BASE_URL = os.environ.get("LINEAGEWEAVE_TEST_KEYCLOAK_BASE_URL", "http://localhost:18080")
_VALKEY_URL = os.environ.get("LINEAGEWEAVE_TEST_VALKEY_URL", "redis://localhost:16379/0")
_REALM = "lineageweave-demo"
+_demo_analyst_token_cache: tuple[str, int] | None = None
_MIGRATION_PATH = Path(__file__).resolve().parents[2] / "migrations" / "0001_initial_schema.sql"
_REGISTRY_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0018_analysis_run_registry.sql"
_RETENTION_MIGRATION = Path(__file__).resolve().parents[2] / "migrations" / "0020_analysis_run_retention_purge.sql"
@@ -273,10 +275,46 @@ def _fetch_demo_analyst_token() -> str:
return token_response["access_token"]
+def _current_demo_analyst_token() -> str:
+ """Reuse the live token until its issuer-recorded expiration instant."""
+ global _demo_analyst_token_cache
+
+ if _demo_analyst_token_cache is not None:
+ token, expires_at = _demo_analyst_token_cache
+ if time.time() < expires_at:
+ return token
+
+ token = _fetch_demo_analyst_token()
+ expires_at = int(jwt.decode(token, options={"verify_signature": False})["exp"])
+ _demo_analyst_token_cache = (token, expires_at)
+ return token
+
+
@pytest.fixture
def demo_analyst_token() -> str:
- """Return a fresh token so long-running suites cannot outlive its TTL."""
- return _fetch_demo_analyst_token()
+ """Return a cached live token, refreshing it at the issuer's exact expiry."""
+ return _current_demo_analyst_token()
+
+
+def test_demo_analyst_token_cache_refreshes_only_at_issuer_expiry(monkeypatch: pytest.MonkeyPatch) -> None:
+ """The integration suite reuses a token before ``exp`` and refreshes at ``exp``."""
+ global _demo_analyst_token_cache
+
+ issued = iter(
+ (
+ jwt.encode({"exp": 101}, "test-key-for-cache-expiry-check-1", algorithm="HS256"),
+ jwt.encode({"exp": 202}, "test-key-for-cache-expiry-check-2", algorithm="HS256"),
+ )
+ )
+ monkeypatch.setattr(__name__ + "._fetch_demo_analyst_token", lambda: next(issued))
+ monkeypatch.setattr(__name__ + ".time.time", lambda: 100)
+ _demo_analyst_token_cache = None
+ first = _current_demo_analyst_token()
+ assert _current_demo_analyst_token() == first
+
+ monkeypatch.setattr(__name__ + ".time.time", lambda: 101)
+ assert _current_demo_analyst_token() != first
+ _demo_analyst_token_cache = None
@pytest.fixture
From d314855c7e6a7499db5cae1eced303ec187e3fdb Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 10:03:08 +0900
Subject: [PATCH 068/393] fix(ui): keep analysis internals out of action copy
---
frontend/src/App.test.tsx | 46 +++++++++++++++++++--------------------
frontend/src/App.tsx | 40 +++++++++++++++++++---------------
2 files changed, 45 insertions(+), 41 deletions(-)
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 414cadd5e..1004c28a1 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -649,7 +649,7 @@ describe("App, authenticated", () => {
JSON.stringify({
detail:
payload.run_kind_code === "analysis_run_tepp"
- ? "Connect a TEPP transport from a Failed TEPP row; this endpoint does not invent a measurement."
+ ? "Ask an administrator to restore temporal measurement, then re-run this source set."
: "Rebuild the period report from the Reports panel.",
}),
{ status: 422, headers: { "Content-Type": "application/json" } },
@@ -3248,10 +3248,10 @@ describe("App, authenticated", () => {
expect(await screen.findByRole("heading", { name: "Analysis runs" })).toBeInTheDocument();
const list = screen.getByRole("list", { name: "Analysis runs" });
expect(list).toHaveTextContent("Lineage reconstruction · Succeeded · Demo Corp");
- expect(list).toHaveTextContent("TEPP measurement · Failed · Demo Corp");
+ expect(list).toHaveTextContent("Temporal measurement · Failed · Demo Corp");
expect(list).toHaveTextContent("Period report · Succeeded · Demo Corp");
expect(list).toHaveTextContent(
- "Open this run to see why it failed, then connect the measurement service and re-run.",
+ "Open this run to review the failure, ask an administrator to restore analysis, then re-run.",
);
expect(list).toHaveTextContent("3 documents");
expect(list).not.toHaveTextContent("postgresql://");
@@ -3341,15 +3341,15 @@ describe("App, authenticated", () => {
await userEvent.click(
screen.getByRole("button", {
- name: "Open analysis run: TEPP measurement · Failed · Demo Corp",
+ name: "Open analysis run: Temporal measurement · Failed · Demo Corp",
}),
);
expect(
- await screen.findByRole("heading", { name: "TEPP measurement · Failed · Demo Corp" }),
+ await screen.findByRole("heading", { name: "Temporal measurement · Failed · Demo Corp" }),
).toBeInTheDocument();
const teppHistory = screen.getByRole("list", { name: "Analysis run status history" });
expect(teppHistory).toHaveTextContent("Failed 2026-01-12 12:37 · tepp_not_available");
- expect(screen.getByText(/cutoff corpus TEPP would measure/i)).toBeInTheDocument();
+ expect(screen.getByText(/source set temporal measurement would measure/i)).toBeInTheDocument();
expect(teppHistory).not.toHaveTextContent("Succeeded");
});
@@ -3401,7 +3401,7 @@ describe("App, authenticated", () => {
expect(screen.queryByRole("heading", { name: "Body this run knew" })).not.toBeInTheDocument();
});
- it("tells a running lineage run to refresh the durable outbox", async () => {
+ it("tells a running lineage run to refresh its progress", async () => {
stubBackend({ runningLineageRun: true });
render( );
@@ -3409,12 +3409,12 @@ describe("App, authenticated", () => {
name: "Open analysis run: Lineage reconstruction · Running · Demo Corp",
});
expect(lineageButton).toHaveTextContent(
- "Refresh this run. Start already queued the work on the durable outbox.",
+ "Refresh this run to see the latest progress.",
);
await userEvent.click(lineageButton);
expect(screen.getByRole("button", { name: "Start reconstruction" })).toBeInTheDocument();
expect(
- screen.getAllByText("Refresh this run. Start already queued the work on the durable outbox."),
+ screen.getAllByText("Refresh this run to see the latest progress."),
).not.toHaveLength(0);
});
@@ -3427,14 +3427,14 @@ describe("App, authenticated", () => {
name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp",
});
const teppButton = screen.getByRole("button", {
- name: "Open analysis run: TEPP measurement · Failed · Demo Corp",
+ name: "Open analysis run: Temporal measurement · Failed · Demo Corp",
});
expect(lineageButton).toHaveTextContent(
"Open this run to see why it failed, then retry reconstruction from a current snapshot.",
);
expect(lineageButton).not.toHaveTextContent("measurement service");
expect(teppButton).toHaveTextContent(
- "Open this run to see why it failed, then connect the measurement service and re-run.",
+ "Open this run to review the failure, ask an administrator to restore analysis, then re-run.",
);
expect(teppButton).not.toHaveTextContent("reconstruction");
});
@@ -3734,17 +3734,17 @@ describe("App, authenticated", () => {
await userEvent.click(
await screen.findByRole("button", {
- name: "Open analysis run: TEPP measurement · Pending · Demo Corp",
+ name: "Open analysis run: Temporal measurement · Pending · Demo Corp",
}),
);
expect(
- await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."),
+ await screen.findByText("These posts are the cutoff corpus temporal measurement will measure once this run finishes."),
).toBeInTheDocument();
expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument();
- expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/this temporal measurement run measured/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start reconstruction" })).not.toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Start TEPP measurement" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Start temporal measurement" })).toBeInTheDocument();
});
it("starts a pending TEPP run through tepp_client and does not invent a theta", async () => {
@@ -3753,12 +3753,12 @@ describe("App, authenticated", () => {
await userEvent.click(
await screen.findByRole("button", {
- name: "Open analysis run: TEPP measurement · Pending · Demo Corp",
+ name: "Open analysis run: Temporal measurement · Pending · Demo Corp",
}),
);
- await userEvent.click(screen.getByRole("button", { name: "Start TEPP measurement" }));
+ await userEvent.click(screen.getByRole("button", { name: "Start temporal measurement" }));
expect(
- await screen.findByRole("heading", { name: "TEPP measurement · Failed · Demo Corp" }),
+ await screen.findByRole("heading", { name: "Temporal measurement · Failed · Demo Corp" }),
).toBeInTheDocument();
expect(screen.getByText(/tepp_not_available/)).toBeInTheDocument();
expect(screen.queryByText(/theta/i)).not.toBeInTheDocument();
@@ -3775,16 +3775,16 @@ describe("App, authenticated", () => {
await userEvent.click(
await screen.findByRole("button", {
- name: "Open analysis run: TEPP measurement · Failed · Demo Corp",
+ name: "Open analysis run: Temporal measurement · Failed · Demo Corp",
}),
);
expect(
await screen.findByText(
- "Connect a TEPP transport from this Failed row. Request a lineage reconstruction does not invent a measurement.",
+ "Ask an administrator to restore temporal measurement, then re-run this source set.",
),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Request a new TEPP measurement" })).not.toBeInTheDocument();
- expect(screen.queryByRole("heading", { name: "TEPP measurement · Pending · Demo Corp" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "Temporal measurement · Pending · Demo Corp" })).not.toBeInTheDocument();
expect(
fetchMock.mock.calls.some(
(call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST",
@@ -3798,11 +3798,11 @@ describe("App, authenticated", () => {
await userEvent.click(
await screen.findByRole("button", {
- name: "Open analysis run: TEPP measurement · Succeeded · Demo Corp",
+ name: "Open analysis run: Temporal measurement · Succeeded · Demo Corp",
}),
);
expect(
- await screen.findByText("These posts are the cutoff corpus this TEPP run measured."),
+ await screen.findByText("These posts are the cutoff corpus this temporal measurement run measured."),
).toBeInTheDocument();
expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument();
});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 179c92952..d91eb5b28 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2654,7 +2654,13 @@ function PostDetailPopup({
}
function analysisRunCaption(run: AnalysisRun): string {
- return [run.run_kind_label, run.status_label, run.scope_entity_name ?? run.scope_kind_label]
+ const kindLabel =
+ run.run_kind_code === "analysis_run_tepp"
+ ? "Temporal measurement"
+ : run.run_kind_code === "analysis_run_topic_lineage"
+ ? "Topic journey analysis"
+ : run.run_kind_label;
+ return [kindLabel, run.status_label, run.scope_entity_name ?? run.scope_kind_label]
.filter(Boolean)
.join(" · ");
}
@@ -2674,9 +2680,9 @@ function analysisRunNextAction(run: AnalysisRun): string | null {
case "analysis_run_lineage":
return "Open this run, then start reconstruction. Reconstruction has not started yet.";
case "analysis_run_tepp":
- return "Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result.";
+ return "Open this run to confirm the source posts, then start temporal measurement.";
case "analysis_run_topic_lineage":
- return "Open this run to confirm which posts TEPP will thread into topic lineage. Topic-lineage analysis has not started yet — this is not a calibrated topic result.";
+ return "Open this run to confirm the source posts, then start topic journey analysis.";
case "analysis_run_report":
return "Open this run to confirm which posts the period report will use. The report has not been built yet.";
default: {
@@ -2687,9 +2693,9 @@ function analysisRunNextAction(run: AnalysisRun): string | null {
case "analysis_status_failed":
switch (run.run_kind_code) {
case "analysis_run_tepp":
- return "Open this run to see why it failed, then connect the measurement service and re-run.";
+ return "Open this run to review the failure, ask an administrator to restore analysis, then re-run.";
case "analysis_run_topic_lineage":
- return "Open this run to see why it failed, then connect the TEPP transport and re-run.";
+ return "Open this run to review the failure, ask an administrator to restore analysis, then re-run.";
case "analysis_run_lineage":
return "Open this run to see why it failed, then retry reconstruction from a current snapshot.";
case "analysis_run_report":
@@ -2700,7 +2706,7 @@ function analysisRunNextAction(run: AnalysisRun): string | null {
}
}
case "analysis_status_running":
- return "Refresh this run. Start already queued the work on the durable outbox.";
+ return "Refresh this run to see the latest progress.";
case "analysis_status_succeeded":
case "analysis_status_cancelled":
case null:
@@ -2719,7 +2725,7 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string {
switch (run.run_kind_code) {
case "analysis_run_tepp":
return (
- "No posts were available at this cutoff for TEPP to measure. " +
+ "No posts were available at this cutoff for temporal measurement. " +
"Open a later run, or ask an administrator to capture a newer snapshot."
);
case "analysis_run_topic_lineage":
@@ -2753,15 +2759,15 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string {
function analysisRunCorpusHint(run: AnalysisRun): string | null {
const isTopicLineage = run.run_kind_code === "analysis_run_topic_lineage";
if (run.run_kind_code !== "analysis_run_tepp" && !isTopicLineage) return null;
- const service = isTopicLineage ? "topic-lineage" : "TEPP";
- const result = isTopicLineage ? "a topic-identity result" : "a calibrated result";
+ const service = isTopicLineage ? "topic journey analysis" : "temporal measurement";
+ const result = isTopicLineage ? "a topic journey result" : "a calibrated result";
const verb = isTopicLineage ? "thread" : "measure";
const verbPast = isTopicLineage ? "threaded" : "measured";
switch (run.status_code) {
case "analysis_status_failed":
return (
- `These posts are the cutoff corpus ${service} would ${verb}. Connect a TEPP ` +
- `transport, then re-run, to replace Failed with ${result}.`
+ `These posts are the source set ${service} would ${verb}. ` +
+ `Ask an administrator to restore analysis, then re-run to produce ${result}.`
);
case "analysis_status_succeeded":
return `These posts are the cutoff corpus this ${service} run ${verbPast}.`;
@@ -2900,7 +2906,7 @@ function analysisRunCanStart(run: AnalysisRun): boolean {
function analysisRunStartLabel(run: AnalysisRun): string {
if (run.run_kind_code === "analysis_run_tepp") {
- return "Start TEPP measurement";
+ return "Start temporal measurement";
}
if (run.run_kind_code === "analysis_run_topic_lineage") {
return "Start topic lineage";
@@ -3192,7 +3198,7 @@ function AnalysisRunsPanel({
>
{starting
? selected.run_kind_code === "analysis_run_tepp"
- ? "Submitting the TEPP request..."
+ ? "Starting temporal measurement..."
: selected.run_kind_code === "analysis_run_topic_lineage"
? "Submitting the topic-lineage request..."
: "Reconstructing the cutoff bag..."
@@ -3202,10 +3208,8 @@ function AnalysisRunsPanel({
{analysisRunCanRequestTeppRetry(selected) && (
{selected.run_kind_code === "analysis_run_topic_lineage"
- ? "Connect a TEPP transport from this Failed row. Request a " +
- "lineage reconstruction does not invent a topic model."
- : "Connect a TEPP transport from this Failed row. Request a lineage " +
- "reconstruction does not invent a measurement."}
+ ? "Ask an administrator to restore topic journey analysis, then re-run this source set."
+ : "Ask an administrator to restore temporal measurement, then re-run this source set."}
)}
{analysisRunReportPeriod(selected) && onSelectReportPeriod && (
@@ -3390,7 +3394,7 @@ function RankingsPanel({
onClick={() => onSelectPost(hit.post_id)}
>
{hit.post_title}
- {t("Rankings · rankweave")}
+ {t("Evidence ranking")}
{tf("rank {rank}", { rank: String(hit.fused_rank) })}
{(hit.channel_evidence ?? []).length > 0 ? (
From 41c6caefc96f822321998683143053dbc7726b31 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 10:05:08 +0900
Subject: [PATCH 069/393] docs(gaps): refresh dashboard exact-head evidence
---
docs/product-technical-gap-baseline.md | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 00008e677..d30b0a8f2 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -1,8 +1,8 @@
# Product & Technical Gap Baseline
-> Dashboard delivery snapshot: 2026-08-26 07:26 KST. Protected `main` was
+> Dashboard delivery snapshot: 2026-08-26 10:06 KST. Protected `main` was
> `494b54e2245040bcf02b45376f221c37cd437e76`. Dashboard PR #640 exact
-> pre-synchronization head was `dd134e77`; this branch is not
+> observed head was `d314855c`; this branch is not
> protected-main release evidence.
## Operations Dashboard PRD/TRD traceability
@@ -135,13 +135,14 @@ context only.
| PR | Exact observed head | Merge/check state at this snapshot |
| ---: | --- | --- |
+| #681 | `3e0fa644` | stacked fast-mlsirm pair-posterior contract pin; exact-head checks queued and independent review required |
| #667 | `425de329` | current governance/gap evidence refresh; exact-head checks and independent review required |
| #663 | `e65fd29c` | consolidates project ontology traversal and #632 content; exact-head checks and independent review required |
| #658 | `f497a6e8` | evidence-honest Global Ask cutoff with revision-interval live-after semantics |
| #657 | `a59a2023` | fail-closed TEPP asynchronous lifecycle persistence; executable producer evidence remains required |
| #644 | `ed8d97f3` | native-surface code splitting with modal-focus regression coverage |
| #643 | `7fb4d18c` | accessible status-notice surfaces |
-| #640 | `dd134e77` | operations-dashboard contract alignment |
+| #640 | `d314855c` | operations-dashboard contract alignment; exact-head checks queued and independent review required |
| #639 | `8da485d3` | exact-head checks and independent review required |
| #632 | `cad4debf` | semantic provenance repair structurally included by #663 |
| #631 | `e6b4f0c4` | documentation-only queue snapshot requires current-main refresh or closure |
From d6c94512589a51fdc99d1a7fa42870448f3b3d8f Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 10:08:29 +0900
Subject: [PATCH 070/393] fix(api): return customer-action analysis errors
---
backend/app/analysis_run_ingestion.py | 6 ++----
tests/test_analysis_run_create.py | 6 +++---
2 files changed, 5 insertions(+), 7 deletions(-)
diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py
index 7335f6fb9..289e3df7f 100644
--- a/backend/app/analysis_run_ingestion.py
+++ b/backend/app/analysis_run_ingestion.py
@@ -640,14 +640,12 @@ def _require_lineage_create_kind(run_kind_code: str) -> None:
if run_kind_code == _TEPP_RUN_KIND:
raise AnalysisRunCreateError(
422,
- "Connect a TEPP transport from a Failed TEPP row; this endpoint "
- "does not invent a measurement.",
+ "Open the failed temporal measurement, ask an administrator to restore analysis, then re-run it.",
)
if run_kind_code == _TOPIC_LINEAGE_RUN_KIND:
raise AnalysisRunCreateError(
422,
- "Connect a TEPP transport from a Failed topic-lineage row; this "
- "endpoint does not invent a topic model.",
+ "Open the failed topic journey analysis, ask an administrator to restore analysis, then re-run it.",
)
if run_kind_code == _REPORT_RUN_KIND:
raise AnalysisRunCreateError(
diff --git a/tests/test_analysis_run_create.py b/tests/test_analysis_run_create.py
index ef9dc9f71..6ede3f863 100644
--- a/tests/test_analysis_run_create.py
+++ b/tests/test_analysis_run_create.py
@@ -144,11 +144,11 @@ def test_create_rejects_tepp_and_report_kinds_without_a_fake_score() -> None:
with pytest.raises(AnalysisRunCreateError) as tepp:
_require_lineage_create_kind("analysis_run_tepp")
assert tepp.value.status_code == 422
- assert "invent a measurement" in tepp.value.detail
+ assert "restore analysis" in tepp.value.detail
with pytest.raises(AnalysisRunCreateError) as topic_lineage:
_require_lineage_create_kind("analysis_run_topic_lineage")
assert topic_lineage.value.status_code == 422
- assert "invent a topic model" in topic_lineage.value.detail
+ assert "restore analysis" in topic_lineage.value.detail
with pytest.raises(AnalysisRunCreateError) as report:
_require_lineage_create_kind("analysis_run_report")
assert report.value.status_code == 422
@@ -180,7 +180,7 @@ async def _run() -> None:
idempotency_key="client-key-1",
)
assert err.value.status_code == 422
- assert "invent a measurement" in err.value.detail
+ assert "restore analysis" in err.value.detail
asyncio.run(_run())
From c77acc776fb90708e16c3a399541d52348044fce Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 10:14:17 +0900
Subject: [PATCH 071/393] fix(ui): keep ranking evidence customer-facing
---
frontend/src/App.tsx | 2 +-
frontend/src/components/OperationsDashboard.tsx | 4 ++--
lineageweave/leftover_pairs.py | 6 +++++-
3 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d91eb5b28..8658d49b4 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -3385,7 +3385,7 @@ function RankingsPanel({
"Compare recency and content-relevance evidence, then open the source post before acting. This rank is not a performance score.",
)}
-
+
{ranking.rankings.map((hit) => (
{influence.occurred_at.slice(0, 10)}
{topicStateLabels[influence.topic_state_code]}
{influence.model_influence}
- {influence.uncertainty_lower_value}–{influence.uncertainty_upper_value} · {influence.uncertainty_method_code}
- weight {influence.membership_weight} · {influence.membership_evidence_sha256}
+ {influence.uncertainty_lower_value}–{influence.uncertainty_upper_value}
+ {influence.membership_weight}
onOpenPost(influence.post_id)}>근거 글 열기
))}
diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py
index 87a563bc1..572749ffe 100644
--- a/lineageweave/leftover_pairs.py
+++ b/lineageweave/leftover_pairs.py
@@ -85,7 +85,11 @@ def leftover_map_from_residual(
if index < len(result.singular_values)
else 0.0
),
- leftover_share=float(result.axis_shares[index]),
+ leftover_share=(
+ float(result.axis_shares[index])
+ if index < len(result.axis_shares)
+ else 0.0
+ ),
)
for index in range(_LEFTOVER_MAP_AXES)
)
From 85cb3a26aaaed85d886a0b3e2436ca424c575966 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 10:10:59 +0900
Subject: [PATCH 072/393] feat(ask): link citations to evidence timeline
---
CHANGELOG.md | 5 +
backend/app/global_ask_queue.py | 4 +
backend/app/post_chat_ingestion.py | 9 +
docs/adr/0225-ask-answer-evidence-timeline.md | 61 ++++++
docs/adr/README.md | 1 +
docs/product-requirements.md | 13 ++
docs/product-technical-gap-baseline.md | 1 +
docs/storybook-inventory.md | 1 +
frontend/src/App.css | 156 +++++++++++++++
frontend/src/App.test.tsx | 27 +++
frontend/src/App.tsx | 58 +-----
frontend/src/api.ts | 8 +
.../components/AskAnswerTimeline.stories.tsx | 70 +++++++
.../src/components/AskAnswerTimeline.test.tsx | 102 ++++++++++
frontend/src/components/AskAnswerTimeline.tsx | 188 ++++++++++++++++++
frontend/src/i18n.ts | 52 +++++
lineageweave/post_chat.py | 26 +++
tests/test_global_ask_queue.py | 53 +++++
tests/test_global_ask_sources.py | 4 +
tests/test_post_chat.py | 30 +++
20 files changed, 821 insertions(+), 48 deletions(-)
create mode 100644 docs/adr/0225-ask-answer-evidence-timeline.md
create mode 100644 frontend/src/components/AskAnswerTimeline.stories.tsx
create mode 100644 frontend/src/components/AskAnswerTimeline.test.tsx
create mode 100644 frontend/src/components/AskAnswerTimeline.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c33678fe..6f57bce06 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,11 @@ All notable changes to this project are documented here. Format follows
### Added
+- Global Ask answers now link numbered citations to authorized event cards in
+ both directions. Each card names event time or the record-time fallback and
+ opens the focused evidence layer or full source post without inventing a
+ Project Journey.
+
- ADR 0210's Dashboard consumer now persists a normalized, exact-provenance
projection for TEPP temporal topics and fast-mlsirm case-deletion model
influence. The API authorizes the fitted analysis scope before returning
diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py
index c7e570d81..7174189c1 100644
--- a/backend/app/global_ask_queue.py
+++ b/backend/app/global_ask_queue.py
@@ -35,6 +35,7 @@
from lineageweave.post_chat import (
PostChatClient,
cited_post_evidence,
+ cited_post_events,
cited_post_summaries,
)
from lineageweave.temporal_expressions import resolve_korean_relative_time
@@ -260,6 +261,7 @@ def can_see(row: asyncpg.Record) -> bool:
"answer_text": "",
"cited_post_ids": [],
"cited_posts": [],
+ "cited_events": [],
"source_post_ids": [],
"cited_post_evidence": [],
"lineage_graph": {"nodes": [], "edges": [], "truncated": False},
@@ -304,11 +306,13 @@ def can_see(row: asyncpg.Record) -> bool:
lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids)
images = await cited_post_images(conn, cited_ids)
cited_posts = cited_post_summaries(sources, cited_ids)
+ cited_events = cited_post_events(sources, cited_ids)
cited_evidence = cited_post_evidence(sources, cited_ids)
return {
"answer_text": answer.answer_text,
"cited_post_ids": cited_ids,
"cited_posts": cited_posts,
+ "cited_events": cited_events,
"cited_post_evidence": cited_evidence,
"cited_post_images": images,
"source_post_ids": [source.post_id for source in sources],
diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py
index 4ca9e2f2d..2777e8c66 100644
--- a/backend/app/post_chat_ingestion.py
+++ b/backend/app/post_chat_ingestion.py
@@ -579,6 +579,9 @@ async def gather_global_chat_sources(
if post_id in lineage_neighbor_id_set and anchor_is_visible
else ()
)
+ event_occurred_at = row.get("event_occurred_at")
+ created_at = row.get("created_at")
+ observed_at = event_occurred_at or created_at
sources.append(
ChatSourceDocument(
post_id,
@@ -589,6 +592,12 @@ async def gather_global_chat_sources(
+ semantic_facts.get(post_id, ())
+ lineage_fact
+ time_axis_evidence_fact(row, time_filter_active=time_filter_active),
+ observed_at=observed_at.isoformat() if observed_at else None,
+ time_axis_code="event_occurred_at"
+ if event_occurred_at is not None
+ else "created_at"
+ if created_at is not None
+ else None,
)
)
return sources
diff --git a/docs/adr/0225-ask-answer-evidence-timeline.md b/docs/adr/0225-ask-answer-evidence-timeline.md
new file mode 100644
index 000000000..fce742bcf
--- /dev/null
+++ b/docs/adr/0225-ask-answer-evidence-timeline.md
@@ -0,0 +1,61 @@
+# ADR 0225: Ask answers link citations to an evidence timeline
+
+- Status: Accepted
+- Date: 2026-08-26
+- Related: [0039](0039-global-ask-agent-source-boundary.md), [0090](0090-global-ask-lineage-timeline-expansion.md), [0153](0153-ask-evidence-layer-popup.md), [0202](0202-ask-event-time-filter.md)
+
+## Context
+
+Global Ask returns an answer and authorized cited posts, but the answer and
+source controls are visually separate. A reader cannot select citation `[2]`
+and land on the corresponding event, or select an event and return to the
+answer citation. The current response also omits the cited source's observed
+instant and named clock, so the frontend cannot construct an honest event-time
+list without guessing from the lineage graph.
+
+## Decision
+
+1. A Global Ask result returns `cited_events` in citation order. Each entry is
+ derived from the same authorized `ChatSourceDocument` that was admitted to
+ the answer and contains only its post id, title, persisted observed instant,
+ and clock code. `event_occurred_at` is preferred; `created_at` is the named
+ fallback. A missing instant remains absent.
+2. The answer renders citations `[1]..[n]` from `cited_posts`/`cited_events`.
+ Selecting a citation focuses and highlights its event card. Selecting that
+ card focuses and highlights the matching citation. Both directions preserve
+ the citation number even when cards are chronologically ordered.
+3. Every event card opens the existing evidence layer and the authorized full
+ post. The cards show the named source clock and stored evidence; they do not
+ expose provider, package, schema, hash, environment, or model-run detail.
+4. This surface is an **answer evidence timeline**, not a Project Journey.
+ Chronological ordering alone does not create a predecessor, branch, project
+ start, or causal relation. The separate Project Journey contract continues
+ to require a persisted TEPP TDT/CHRONOS result under ADR 0206.
+5. A commercial perspective or recommended response may appear only inside the
+ contextual-orchestrator answer when the cited event progression supports it.
+ The frontend never manufactures a recommendation from dates, titles, or
+ citation order. Customer copy tells the reader which evidence or source to
+ inspect next and does not explain internal implementation boundaries.
+6. The interaction uses native buttons, visible focus, `aria-pressed`, a live
+ selection status, no color-only state, and no animated scrolling. It remains
+ a single column on narrow viewports and a conversation/timeline split when
+ space permits.
+
+## Consequences
+
+- The reader can move between an answer claim and its source event without
+ losing context.
+- Event time and record time remain distinguishable without inventing dates.
+- Existing evidence-popup and post-detail authorization paths remain the only
+ source-opening paths.
+
+## Verification
+
+- Backend tests prove citation-order preservation, clock selection, absent-time
+ behavior, and unknown-citation removal.
+- Component and Storybook interaction tests prove both focus directions,
+ source opening, keyboard semantics, empty time, narrow layout, and
+ customer-facing copy.
+- Authenticated Compose screenshots cover desktop and narrow viewports with
+ synthetic data.
+
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 492097d78..fc75367d0 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -23,6 +23,7 @@ decision from them.
| [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) |
| [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) |
| Canonical Docker Compose project (`name: lineageweave`) | [0224](0224-canonical-compose-project-name.md) |
+| Ask answer citation and evidence-timeline interaction | [0225](0225-ask-answer-evidence-timeline.md) |
[0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/).
diff --git a/docs/product-requirements.md b/docs/product-requirements.md
index 36e5fcb44..dbce16860 100644
--- a/docs/product-requirements.md
+++ b/docs/product-requirements.md
@@ -96,6 +96,19 @@ stale evidence from a previously opened post. An open lifecycle has a cited
start, a missing end, and nullable elapsed time; a resolved lifecycle links both
endpoint sources and names the source clock used for each instant.
+### PRD-FR-5A — Ask answer evidence navigation
+
+- Link each numbered Ask citation to one authorized event card and preserve the
+ same number when cards are ordered by observed time.
+- Move focus citation-to-card and card-to-citation, then open the existing
+ evidence layer or full source post.
+- Name `event_occurred_at` or the `created_at` fallback; never turn chronology
+ into a project start, predecessor, branch, or recommended response.
+
+Acceptance: keyboard selection works in both directions, every card opens its
+authorized source, missing time stays explicit, and any commercial next action
+comes from the cited orchestrator answer rather than frontend inference.
+
### PRD-FR-6 — Measurement boundary
- Consume TEPP accepted/completed wire contracts and fast-mlsirm outputs; do
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index d30b0a8f2..ace1e0ffa 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -13,6 +13,7 @@
| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending |
| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated synthetic runtime passed with the honest zero-result state |
| Project-specific journey | Explicit source/semantic project membership plus provenance-bearing TEPP TDT/CHRONOS predecessor, branch, and transition results | Candidate API preserves every explicit project membership, but its local event-time sort is only an observed-event list. It is no longer labeled as a Project Journey. Full journey delivery remains open until the accepted TEPP producer artifact is persisted and rendered; no fixed sales/order start or nearest-date edge is accepted. |
+| Ask answer citation-to-event navigation | ADR 0225; authorized cited source, observed source clock, focused evidence layer, and full-post navigation | Stacked candidate renders numbered answer citations and chronologically ordered evidence cards with bidirectional focus. Event and record clocks stay distinct; this list does not claim a Project Journey. Storybook and component interaction evidence are included; authenticated runtime screenshots remain required at the exact candidate head. |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending |
| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending |
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index b4991791d..36f6fbb44 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -6,6 +6,7 @@ operator-facing control you can click before changing product CSS.
| Story | Operator next action | Token / module |
|---|---|---|
| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, repeat issue, or topic-context influence. `TopicInfluenceAccepted` preserves exact ties, multiple membership, time states, uncertainty, and source actions; `EvidenceReady` shows the producer-contract unavailable state. `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover mobile, scoped-empty, explicit evidence-absence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard`, `TopicContextInfluence` |
+| `Ask Agent/AnswerEvidenceTimeline` | Select an answer citation to focus its event card, select the card to return to the answer, then open its evidence or source post. `MissingObservedTime` keeps an absent event clock explicit and `NarrowViewport` verifies the single-column interaction. | `--color-accent-*`, `--radius-panel`, `--size-control-min`, `AskAnswerTimeline` |
| `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` |
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
| `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` |
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 0bfe4f13c..c9c56120d 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1050,6 +1050,162 @@
color: var(--text);
}
+.ask-answer-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.9fr);
+ gap: 1.5rem;
+ align-items: start;
+}
+
+.ask-conversation,
+.ask-evidence-timeline {
+ min-width: 0;
+}
+
+.ask-message {
+ max-width: 90%;
+ padding: var(--space-panel-block);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-panel);
+ background: var(--surface-muted);
+}
+
+.ask-message + .ask-message {
+ margin-top: var(--space-panel-block);
+}
+
+.ask-message-question {
+ margin-left: auto;
+ border-color: var(--color-accent-border);
+ background: var(--color-accent-background);
+}
+
+.ask-message-speaker {
+ display: block;
+ margin-bottom: var(--space-control-gap);
+ color: var(--text-muted);
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.ask-message p,
+.ask-evidence-timeline > p {
+ margin: 0;
+}
+
+.ask-inline-citations {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-control-gap);
+ margin-top: var(--space-panel-block);
+}
+
+.ask-inline-citation {
+ min-width: var(--size-control-min);
+ min-height: var(--size-control-min);
+ border: 0;
+ border-bottom: 2px solid currentColor;
+ background: transparent;
+ color: var(--color-accent);
+ cursor: pointer;
+ font-weight: 700;
+}
+
+.ask-inline-citation[aria-pressed="true"] {
+ outline: 3px solid var(--color-focus-border);
+ outline-offset: 2px;
+}
+
+.ask-next-action {
+ margin-top: var(--space-panel-block);
+ padding: var(--space-panel-block);
+ border-left: 4px solid var(--color-accent-gold);
+ background: var(--color-code-background);
+}
+
+.ask-evidence-timeline > ol {
+ position: relative;
+ display: grid;
+ gap: var(--space-panel-block);
+ margin: var(--space-panel-block) 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.ask-evidence-timeline > ol::before {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0.75rem;
+ width: 2px;
+ background: var(--color-accent-border);
+ content: "";
+}
+
+.ask-evidence-timeline article {
+ position: relative;
+ margin-left: 2rem;
+ padding: var(--space-panel-block);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-panel);
+ background: var(--surface);
+}
+
+.ask-event-selected article {
+ border-color: var(--color-focus-border);
+ outline: 2px solid var(--color-focus-border);
+ outline-offset: 2px;
+}
+
+.ask-event-select {
+ display: flex;
+ width: 100%;
+ min-height: var(--size-control-min);
+ align-items: flex-start;
+ gap: var(--space-panel-block);
+ border: 0;
+ padding: 0;
+ background: transparent;
+ color: var(--text);
+ cursor: pointer;
+ text-align: left;
+}
+
+.ask-event-select strong,
+.ask-event-select small {
+ display: block;
+}
+
+.ask-event-select small {
+ margin-top: var(--space-control-gap);
+ color: var(--text-muted);
+}
+
+.ask-event-marker {
+ position: relative;
+ z-index: 1;
+ flex: 0 0 auto;
+ color: var(--color-accent);
+ font-weight: 700;
+}
+
+.ask-event-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-control-gap);
+ margin-top: var(--space-panel-block);
+}
+
+@media (max-width: 60rem) {
+ .ask-answer-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .ask-message {
+ max-width: 100%;
+ }
+}
+
.evidence-panel {
position: fixed;
top: 0;
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 1004c28a1..a33ead143 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -1737,6 +1737,14 @@ describe("App, authenticated", () => {
answer_text: "The cited project is supported by the stored semantic evidence.",
cited_post_ids: ["post-2"],
cited_posts: [{ post_id: "post-2", post_title: "Linked post" }],
+ cited_events: [
+ {
+ post_id: "post-2",
+ post_title: "Linked post",
+ observed_at: "2026-08-01T00:00:00Z",
+ time_axis_code: "event_occurred_at",
+ },
+ ],
cited_post_evidence: [
{
post_id: "post-2",
@@ -2051,6 +2059,25 @@ describe("App, authenticated", () => {
expect(screen.getByRole("button", { name: "View evidence" })).toBeInTheDocument();
});
+ it("links an Ask answer citation to its event card and back", async () => {
+ stubBackend();
+ render( );
+ expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Ask Agent" }));
+ await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?");
+ await userEvent.click(screen.getByRole("button", { name: "Ask" }));
+
+ const citation = await screen.findByRole("button", { name: "Show event 1: Linked post" });
+ const eventCard = screen.getByRole("button", {
+ name: "Return to answer citation 1: Linked post",
+ });
+ await userEvent.click(citation);
+ expect(eventCard).toHaveFocus();
+ expect(screen.getByText(/Event occurred/)).toBeInTheDocument();
+ await userEvent.click(eventCard);
+ expect(citation).toHaveFocus();
+ });
+
it("labels the Customer Master entity level and Keymen side, never the raw lookup code", async () => {
// Live UI finding (2026-08-19): read_customer_master() skipped the
// common_lookup_value join both endpoints elsewhere already use,
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8658d49b4..16b814666 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -94,9 +94,9 @@ import { CutoffKnownBody } from "./components/CutoffKnownBody";
import { LineageEntityPicker } from "./components/LineageEntityPicker";
import { OntologyExplorer } from "./components/OntologyExplorer";
import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup";
+import { AskAnswerTimeline } from "./components/AskAnswerTimeline";
import { PopupCloseButton } from "./components/PopupCloseButton";
import { SimilarVocPanel } from "./components/SimilarVocPanel";
-import { chatEvidenceKindLabel } from "./evidenceKindLabels";
import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav";
import { OperationsDashboard } from "./components/OperationsDashboard";
import { initialWorkspaceDestination } from "./gnbChrome";
@@ -4826,6 +4826,7 @@ function AskAgentPanel({
}) {
const [question, setQuestion] = useState("");
const [answer, setAnswer] = useState(null);
+ const [answeredQuestion, setAnsweredQuestion] = useState("");
const [error, setError] = useState(null);
const [asking, setAsking] = useState(false);
const [evidenceLayerPostId, setEvidenceLayerPostId] = useState(null);
@@ -4837,6 +4838,7 @@ function AskAgentPanel({
setError(null);
try {
setAnswer(await askAgent(accessToken, normalized));
+ setAnsweredQuestion(normalized);
} catch (err) {
setAnswer(null);
setError(orchestratorUnavailableMessage(err, t("Ask Agent")));
@@ -4866,8 +4868,13 @@ function AskAgentPanel({
{answer && (
{t("Answer")}
- {answer.answer_text ? {answer.answer_text}
: null}
- {answer.next_action ? {t(answer.next_action)}
: null}
+
{answer.delivery ? (
{t("Report · alert · MCP")}
@@ -4882,51 +4889,6 @@ function AskAgentPanel({
{answer.delivery.report.source_documents[0]?.resource_uri ?? "lineageweave://posts"}
) : null}
- {answer.cited_posts && answer.cited_posts.length > 0 && (
- <>
- {t("Cited posts")}
-
- >
- )}
{answer.lineage_graph && answer.lineage_graph.nodes.length > 0 ? (
) : null}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 858b871b3..e819a063a 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -443,6 +443,13 @@ export interface CitedPostEvidence {
facts: CitedPostEvidenceFact[];
}
+export interface CitedPostEvent {
+ post_id: string;
+ post_title: string;
+ observed_at: string | null;
+ time_axis_code: "event_occurred_at" | "created_at" | null;
+}
+
export interface ChatAnswer {
post_id: string;
answer_text: string;
@@ -477,6 +484,7 @@ export interface AskAgentResponse {
answer_text: string;
cited_post_ids: string[];
cited_posts?: CitedPostRef[];
+ cited_events?: CitedPostEvent[];
cited_post_evidence?: CitedPostEvidence[];
cited_post_images?: CitedPostImage[];
source_post_ids: string[];
diff --git a/frontend/src/components/AskAnswerTimeline.stories.tsx b/frontend/src/components/AskAnswerTimeline.stories.tsx
new file mode 100644
index 000000000..6078fc2fd
--- /dev/null
+++ b/frontend/src/components/AskAnswerTimeline.stories.tsx
@@ -0,0 +1,70 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
+import { AskAnswerTimeline } from "./AskAnswerTimeline";
+import "../App.css";
+
+const meta = {
+ title: "Ask Agent/AnswerEvidenceTimeline",
+ component: AskAnswerTimeline,
+ parameters: { layout: "fullscreen" },
+ decorators: [(Story) =>
],
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+const args: Story["args"] = {
+ question: "What changed before the revised proposal?",
+ answer: {
+ answer_text: "The account discussion preceded the customer's revised request.",
+ cited_post_ids: ["post-request", "post-discussion"],
+ cited_posts: [
+ { post_id: "post-request", post_title: "Customer revised request" },
+ { post_id: "post-discussion", post_title: "Account discussion" },
+ ],
+ cited_events: [
+ { post_id: "post-request", post_title: "Customer revised request", observed_at: "2026-08-20T09:00:00Z", time_axis_code: "event_occurred_at" },
+ { post_id: "post-discussion", post_title: "Account discussion", observed_at: "2026-08-10T09:00:00Z", time_axis_code: "created_at" },
+ ],
+ cited_post_evidence: [
+ { post_id: "post-request", facts: [{ kind: "semantic_project", text: "project: Synthetic renewal" }] },
+ { post_id: "post-discussion", facts: [{ kind: "semantic_role", text: "actor: Synthetic account owner" }] },
+ ],
+ source_post_ids: ["post-request", "post-discussion"],
+ },
+ onOpenEvidence: () => undefined,
+ onOpenPost: () => undefined,
+};
+
+export const BidirectionalFocus: Story = {
+ args,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const citation = canvas.getByRole("button", { name: "Show event 1: Customer revised request" });
+ const card = canvas.getByRole("button", {
+ name: "Return to answer citation 1: Customer revised request",
+ });
+ await userEvent.click(citation);
+ await expect(card).toHaveFocus();
+ await userEvent.click(card);
+ await expect(citation).toHaveFocus();
+ },
+};
+
+export const MissingObservedTime: Story = {
+ args: {
+ ...args,
+ answer: {
+ ...args.answer,
+ cited_posts: [args.answer.cited_posts![0]],
+ cited_events: [{ ...args.answer.cited_events![0], observed_at: null, time_axis_code: null }],
+ },
+ },
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).getByText("Observed time unavailable")).toBeVisible();
+ },
+};
+
+export const NarrowViewport: Story = {
+ ...BidirectionalFocus,
+ parameters: { viewport: { defaultViewport: "mobile1" } },
+};
diff --git a/frontend/src/components/AskAnswerTimeline.test.tsx b/frontend/src/components/AskAnswerTimeline.test.tsx
new file mode 100644
index 000000000..9f0855f2e
--- /dev/null
+++ b/frontend/src/components/AskAnswerTimeline.test.tsx
@@ -0,0 +1,102 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type { AskAgentResponse } from "../api";
+import { AskAnswerTimeline } from "./AskAnswerTimeline";
+
+const answer: AskAgentResponse = {
+ answer_text: "The revised request followed the initial commercial discussion.",
+ cited_post_ids: ["post-later", "post-earlier"],
+ cited_posts: [
+ { post_id: "post-later", post_title: "Revised request" },
+ { post_id: "post-earlier", post_title: "Initial discussion" },
+ ],
+ cited_events: [
+ {
+ post_id: "post-later",
+ post_title: "Revised request",
+ observed_at: "2026-08-20T09:00:00Z",
+ time_axis_code: "event_occurred_at",
+ },
+ {
+ post_id: "post-earlier",
+ post_title: "Initial discussion",
+ observed_at: "2026-08-10T09:00:00Z",
+ time_axis_code: "created_at",
+ },
+ ],
+ cited_post_evidence: [
+ {
+ post_id: "post-later",
+ facts: [{ kind: "semantic_project", text: "project: Synthetic renewal" }],
+ },
+ ],
+ source_post_ids: ["post-later", "post-earlier"],
+};
+
+describe("AskAnswerTimeline", () => {
+ it("links citation and chronological event focus in both directions", async () => {
+ const user = userEvent.setup();
+ render(
+ undefined}
+ onOpenPost={() => undefined}
+ />,
+ );
+
+ const timeline = screen.getByRole("region", { name: "Answer evidence timeline" });
+ const cards = within(timeline).getAllByRole("article");
+ expect(cards[0]).toHaveAccessibleName("Evidence 2: Initial discussion");
+ expect(cards[1]).toHaveAccessibleName("Evidence 1: Revised request");
+
+ const citation = screen.getByRole("button", { name: "Show event 1: Revised request" });
+ const card = screen.getByRole("button", {
+ name: "Return to answer citation 1: Revised request",
+ });
+ await user.click(citation);
+ expect(card).toHaveFocus();
+ expect(card).toHaveAttribute("aria-pressed", "true");
+ await user.click(card);
+ expect(citation).toHaveFocus();
+ expect(citation).toHaveAttribute("aria-pressed", "true");
+ expect(screen.getByRole("status")).toHaveTextContent("Selected evidence: Revised request");
+ });
+
+ it("opens the evidence layer and full source from the same authorized card", async () => {
+ const user = userEvent.setup();
+ const onOpenEvidence = vi.fn();
+ const onOpenPost = vi.fn();
+ render(
+ ,
+ );
+
+ await user.click(screen.getAllByRole("button", { name: "View evidence" })[0]);
+ expect(onOpenEvidence).toHaveBeenCalledWith("post-earlier");
+ await user.click(screen.getByRole("button", { name: "Open post: Revised request" }));
+ expect(onOpenPost).toHaveBeenCalledWith("post-later");
+ });
+
+ it("names absent time instead of borrowing a lineage timestamp", () => {
+ render(
+ undefined}
+ onOpenPost={() => undefined}
+ />,
+ );
+
+ expect(screen.getByText("Observed time unavailable")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/AskAnswerTimeline.tsx b/frontend/src/components/AskAnswerTimeline.tsx
new file mode 100644
index 000000000..58bc26dfd
--- /dev/null
+++ b/frontend/src/components/AskAnswerTimeline.tsx
@@ -0,0 +1,188 @@
+import { useRef, useState } from "react";
+import type { AskAgentResponse, CitedPostEvent } from "../api";
+import { chatEvidenceKindLabel } from "../evidenceKindLabels";
+import { getLocale, t, tf } from "../i18n";
+
+type Props = {
+ question: string;
+ answer: AskAgentResponse;
+ onOpenEvidence: (postId: string) => void;
+ onOpenPost: (postId: string) => void;
+};
+
+type Citation = {
+ citationNumber: number;
+ postId: string;
+ postTitle: string;
+ event: CitedPostEvent | undefined;
+};
+
+function observedTimeLabel(event: CitedPostEvent | undefined): string {
+ if (!event?.observed_at) return t("Observed time unavailable");
+ const date = new Date(event.observed_at);
+ if (Number.isNaN(date.valueOf())) return t("Observed time unavailable");
+ const formatted = new Intl.DateTimeFormat(getLocale(), {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(date);
+ const axis = event.time_axis_code === "event_occurred_at"
+ ? t("Event occurred")
+ : event.time_axis_code === "created_at"
+ ? t("Record created")
+ : t("Observed time");
+ return `${formatted} · ${axis}`;
+}
+
+function observedEpoch(event: CitedPostEvent | undefined): number | null {
+ if (!event?.observed_at) return null;
+ const epoch = Date.parse(event.observed_at);
+ return Number.isNaN(epoch) ? null : epoch;
+}
+
+/** Links one grounded Ask answer to its authorized source-event cards. */
+export function AskAnswerTimeline({ question, answer, onOpenEvidence, onOpenPost }: Props) {
+ const [selectedPostId, setSelectedPostId] = useState(null);
+ const citationRefs = useRef(new Map());
+ const cardRefs = useRef(new Map());
+ const eventsByPost = new Map(answer.cited_events?.map((event) => [event.post_id, event]));
+ const citations: Citation[] = (answer.cited_posts ?? []).map((post, index) => ({
+ citationNumber: index + 1,
+ postId: post.post_id,
+ postTitle: post.post_title,
+ event: eventsByPost.get(post.post_id),
+ }));
+ const chronological = [...citations].sort((left, right) => {
+ const leftEpoch = observedEpoch(left.event);
+ const rightEpoch = observedEpoch(right.event);
+ if (leftEpoch === null) return rightEpoch === null ? left.citationNumber - right.citationNumber : 1;
+ if (rightEpoch === null) return -1;
+ return leftEpoch - rightEpoch || left.citationNumber - right.citationNumber;
+ });
+
+ function selectCitation(citation: Citation, target: "card" | "citation") {
+ setSelectedPostId(citation.postId);
+ const destination = target === "card"
+ ? cardRefs.current.get(citation.postId)
+ : citationRefs.current.get(citation.postId);
+ destination?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
+ destination?.focus({ preventScroll: true });
+ }
+
+ return (
+
+
+
+
{t("You")}
+
{question}
+
+
+
{t("Ask Agent")}
+ {answer.answer_text ?
{answer.answer_text}
: null}
+ {citations.length ? (
+
+ {citations.map((citation) => (
+ {
+ if (node) citationRefs.current.set(citation.postId, node);
+ else citationRefs.current.delete(citation.postId);
+ }}
+ type="button"
+ className="ask-inline-citation"
+ aria-label={tf("Show event {number}: {title}", {
+ number: citation.citationNumber,
+ title: citation.postTitle,
+ })}
+ aria-pressed={selectedPostId === citation.postId}
+ onClick={() => selectCitation(citation, "card")}
+ >
+ [{citation.citationNumber}]
+
+ ))}
+
+ ) : null}
+
+ {answer.next_action ? {t(answer.next_action)}
: null}
+
+
+ {chronological.length ? (
+
+ {t("Answer evidence timeline")}
+ {t("Select a citation to review the event and open its source.")}
+
+ {chronological.map((citation) => {
+ const facts = answer.cited_post_evidence?.find(
+ (item) => item.post_id === citation.postId,
+ )?.facts ?? [];
+ const images = answer.cited_post_images?.filter(
+ (image) => image.post_id === citation.postId,
+ ) ?? [];
+ const selected = selectedPostId === citation.postId;
+ return (
+
+
+ {
+ if (node) cardRefs.current.set(citation.postId, node);
+ else cardRefs.current.delete(citation.postId);
+ }}
+ type="button"
+ className="ask-event-select"
+ aria-label={tf("Return to answer citation {number}: {title}", {
+ number: citation.citationNumber,
+ title: citation.postTitle,
+ })}
+ aria-pressed={selected}
+ onClick={() => selectCitation(citation, "citation")}
+ >
+ [{citation.citationNumber}]
+
+ {citation.postTitle}
+ {observedTimeLabel(citation.event)}
+
+
+ {facts.length ? (
+
+ {facts.map((fact, index) => (
+
+ {chatEvidenceKindLabel(fact.kind)}
+ : {fact.text}
+
+ ))}
+
+ ) : null}
+ {images.map((image) => (
+
+ {t("Image evidence")}: {image.caption?.trim() ? image.caption : t("Untitled image")}
+ {image.extracted_text ? ` — ${image.extracted_text}` : ""}
+ {image.tags.length ? ` — ${t("Image tags")}: ${image.tags.join(", ")}` : ""}
+
+ ))}
+
+ onOpenEvidence(citation.postId)}>
+ {t("View evidence")}
+
+ onOpenPost(citation.postId)}>
+ {tf("Open post: {label}", { label: citation.postTitle })}
+
+
+
+
+ );
+ })}
+
+ {selectedPostId ? (
+
+ {tf("Selected evidence: {title}", {
+ title: citations.find((citation) => citation.postId === selectedPostId)?.postTitle ?? "",
+ })}
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 9ed35f342..638a23422 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -181,6 +181,19 @@ const TRANSLATIONS: Partial>> = {
"Asking...": "질의 중...",
Answer: "답변",
"Cited posts": "인용된 글",
+ Conversation: "대화",
+ You: "나",
+ "Answer citations": "답변 근거",
+ "Answer evidence timeline": "답변 근거 타임라인",
+ "Select a citation to review the event and open its source.": "답변의 번호를 선택해 해당 사건과 원문을 확인하세요.",
+ "Show event {number}: {title}": "사건 {number} 보기: {title}",
+ "Return to answer citation {number}: {title}": "답변 근거 {number}로 돌아가기: {title}",
+ "Evidence {number}: {title}": "근거 {number}: {title}",
+ "Selected evidence: {title}": "선택한 근거: {title}",
+ "Observed time unavailable": "확인할 수 있는 사건 시각이 없습니다",
+ "Observed time": "확인 시각",
+ "Event occurred": "사건 발생일",
+ "Record created": "기록 생성일 기준",
"Report · alert · MCP": "리포트 · 알림 · MCP",
"{count} evidence documents are linked to this report.": "근거 문서 {count}건이 리포트에 연결됐습니다.",
"You can subscribe to evidence-change alerts.": "근거 변경 알림을 구독할 수 있습니다.",
@@ -688,6 +701,19 @@ const TRANSLATIONS: Partial>> = {
"Asking...": "正在提问...",
Answer: "回答",
"Cited posts": "引用文章",
+ Conversation: "对话",
+ You: "您",
+ "Answer citations": "答案证据",
+ "Answer evidence timeline": "答案证据时间线",
+ "Select a citation to review the event and open its source.": "选择答案编号以查看相应事件和原文。",
+ "Show event {number}: {title}": "查看事件 {number}:{title}",
+ "Return to answer citation {number}: {title}": "返回答案证据 {number}:{title}",
+ "Evidence {number}: {title}": "证据 {number}:{title}",
+ "Selected evidence: {title}": "已选择证据:{title}",
+ "Observed time unavailable": "没有可确认的事件时间",
+ "Observed time": "确认时间",
+ "Event occurred": "事件发生时间",
+ "Record created": "记录创建时间",
"Search related posts": "搜索相关文章",
"Search related posts for: {name}": "搜索与{name}相关的文章",
"Evidence facts": "证据事实",
@@ -1211,6 +1237,19 @@ const TRANSLATIONS: Partial>> = {
"Asking...": "質問中...",
Answer: "回答",
"Cited posts": "引用された投稿",
+ Conversation: "会話",
+ You: "あなた",
+ "Answer citations": "回答の根拠",
+ "Answer evidence timeline": "回答根拠タイムライン",
+ "Select a citation to review the event and open its source.": "回答の番号を選び、該当する出来事と原文を確認してください。",
+ "Show event {number}: {title}": "出来事 {number} を表示: {title}",
+ "Return to answer citation {number}: {title}": "回答根拠 {number} に戻る: {title}",
+ "Evidence {number}: {title}": "根拠 {number}: {title}",
+ "Selected evidence: {title}": "選択した根拠: {title}",
+ "Observed time unavailable": "確認できる出来事の時刻がありません",
+ "Observed time": "確認時刻",
+ "Event occurred": "出来事の発生日時",
+ "Record created": "記録作成日時",
"Search related posts": "関連投稿を検索",
"Search related posts for: {name}": "{name}の関連投稿を検索",
"Evidence facts": "証拠の事実",
@@ -1713,6 +1752,19 @@ const TRANSLATIONS: Partial>> = {
"Asking...": "Đang hỏi...",
Answer: "Câu trả lời",
"Cited posts": "Bài viết được trích dẫn",
+ Conversation: "Cuộc trò chuyện",
+ You: "Bạn",
+ "Answer citations": "Bằng chứng cho câu trả lời",
+ "Answer evidence timeline": "Dòng thời gian bằng chứng trả lời",
+ "Select a citation to review the event and open its source.": "Chọn số trích dẫn để xem sự kiện và mở nguồn gốc.",
+ "Show event {number}: {title}": "Xem sự kiện {number}: {title}",
+ "Return to answer citation {number}: {title}": "Quay lại bằng chứng trả lời {number}: {title}",
+ "Evidence {number}: {title}": "Bằng chứng {number}: {title}",
+ "Selected evidence: {title}": "Bằng chứng đã chọn: {title}",
+ "Observed time unavailable": "Không có thời điểm sự kiện để xác nhận",
+ "Observed time": "Thời điểm xác nhận",
+ "Event occurred": "Thời điểm sự kiện xảy ra",
+ "Record created": "Thời điểm tạo bản ghi",
"Search related posts": "Tìm bài viết liên quan",
"Search related posts for: {name}": "Tìm bài viết liên quan đến {name}",
"Evidence facts": "Sự kiện bằng chứng",
diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py
index 429a66336..d5df68064 100644
--- a/lineageweave/post_chat.py
+++ b/lineageweave/post_chat.py
@@ -64,6 +64,8 @@ class ChatSourceDocument:
post_body: str
graph_facts: tuple[str, ...] = field(default_factory=tuple)
evidence_facts: tuple[str, ...] = field(default_factory=tuple)
+ observed_at: str | None = None
+ time_axis_code: str | None = None
@dataclass(frozen=True)
@@ -93,6 +95,24 @@ def cited_post_summaries(
]
+def cited_post_events(
+ sources: list[ChatSourceDocument] | tuple[ChatSourceDocument, ...],
+ cited_post_ids: tuple[str, ...] | list[str],
+) -> list[dict[str, str | None]]:
+ """Return cited event clocks in citation order without inventing time."""
+ by_id = {source.post_id: source for source in sources}
+ return [
+ {
+ "post_id": source.post_id,
+ "post_title": source.post_title,
+ "observed_at": source.observed_at,
+ "time_axis_code": source.time_axis_code,
+ }
+ for post_id in cited_post_ids
+ if (source := by_id.get(post_id)) is not None
+ ]
+
+
def _buyer_evidence_kind(fact: str) -> str:
if fact.startswith("time axis:"):
return "time_axis"
@@ -172,6 +192,9 @@ def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer
Do not output a reasoning trace. Return the JSON object immediately.
For every part of your answer, track which source number(s) it came from.
+If the question asks for a commercial response, include a concise commercial
+perspective only when the cited event progression supports it; otherwise say
+what evidence is still needed. Never infer it from chronology alone.
Reply with ONLY a JSON object (no markdown fences, no prose) with exactly
these fields:
@@ -190,6 +213,9 @@ def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer
_CHAT_REQUEST_PROMPT_TEMPLATE = """\
Answer the question using ONLY the numbered source documents below. Do not
use outside knowledge or guess. Be concise and preserve the evidence facts.
+If the question asks for a commercial response, include a concise commercial
+perspective only when the cited event progression supports it; otherwise say
+what evidence is still needed. Never infer it from chronology alone.
Write the answer first, then a new line exactly beginning CITED SOURCES:
followed by the 1-based source numbers separated by commas. Cite every
source the answer used; write NONE when the sources do not support an answer.
diff --git a/tests/test_global_ask_queue.py b/tests/test_global_ask_queue.py
index 76b07170c..696b763cd 100644
--- a/tests/test_global_ask_queue.py
+++ b/tests/test_global_ask_queue.py
@@ -7,6 +7,7 @@
from backend.app import global_ask_queue
from backend.app.global_ask_queue import load_job_visibility
+from lineageweave.post_chat import ChatAnswer, ChatSourceDocument
class _AvailableClient:
@@ -173,3 +174,55 @@ async def fetchval(self, query: str, *args):
assert processes == {"queued-process"}
assert process_scope_limited is True
assert has_post_read is True
+
+
+def test_completed_answer_carries_the_cited_source_clock(monkeypatch) -> None:
+ """The UI timeline receives the admitted source clock, not a graph guess."""
+ connection = _Connection(None)
+ pool = _Pool(connection)
+ sources = [
+ ChatSourceDocument(
+ "post-1",
+ "Synthetic event",
+ "body",
+ observed_at="2026-08-21T03:00:00+00:00",
+ time_axis_code="event_occurred_at",
+ )
+ ]
+
+ async def _fake_gather(*_args, **_kwargs):
+ return sources
+
+ async def _fake_graph(*_args, **_kwargs):
+ return {"nodes": [], "edges": [], "truncated": False}
+
+ async def _fake_images(*_args, **_kwargs):
+ return []
+
+ class _AnswerClient:
+ def answer(self, _question, _sources):
+ return ChatAnswer("Grounded answer", ("post-1",))
+
+ monkeypatch.setattr(global_ask_queue, "gather_global_chat_sources", _fake_gather)
+ monkeypatch.setattr(global_ask_queue, "lineage_graphs_for_posts", _fake_graph)
+ monkeypatch.setattr(global_ask_queue, "cited_post_images", _fake_images)
+
+ payload = asyncio.run(
+ global_ask_queue.compute_global_ask_answer(
+ pool,
+ question_text="What happened?",
+ corporate_entity_ids=set(),
+ process_unit_ids=set(),
+ process_scope_limited=False,
+ chat_client=_AnswerClient(),
+ )
+ )
+
+ assert payload["cited_events"] == [
+ {
+ "post_id": "post-1",
+ "post_title": "Synthetic event",
+ "observed_at": "2026-08-21T03:00:00+00:00",
+ "time_axis_code": "event_occurred_at",
+ }
+ ]
diff --git a/tests/test_global_ask_sources.py b/tests/test_global_ask_sources.py
index f7fc31ae7..f7aee9456 100644
--- a/tests/test_global_ask_sources.py
+++ b/tests/test_global_ask_sources.py
@@ -570,6 +570,8 @@ async def fetch(self, query: str, *args):
assert [source.post_id for source in sources] == ["yesterday-event"]
assert TIME_AXIS_EVENT in sources[0].evidence_facts
assert TIME_AXIS_CREATED not in sources[0].evidence_facts
+ assert sources[0].observed_at == "2026-08-21T03:00:00+00:00"
+ assert sources[0].time_axis_code == "event_occurred_at"
def test_global_sources_name_created_at_fallback_when_event_clock_is_missing(
@@ -608,3 +610,5 @@ async def fetch(self, query: str, *args):
assert [source.post_id for source in sources] == ["ingest-yesterday"]
assert TIME_AXIS_CREATED in sources[0].evidence_facts
+ assert sources[0].observed_at == "2026-08-21T03:00:00+00:00"
+ assert sources[0].time_axis_code == "created_at"
diff --git a/tests/test_post_chat.py b/tests/test_post_chat.py
index 9c6d2faf4..3922abd10 100644
--- a/tests/test_post_chat.py
+++ b/tests/test_post_chat.py
@@ -34,6 +34,7 @@
NullPostChatClient,
_render_sources_block,
cited_post_evidence,
+ cited_post_events,
cited_post_summaries,
normalize_chat_question,
parse_chat_response,
@@ -166,6 +167,34 @@ def test_cited_post_summaries_keep_citation_order_and_drop_unknown_ids() -> None
]
+def test_cited_post_events_keep_named_clocks_and_do_not_invent_time() -> None:
+ sources = (
+ ChatSourceDocument(
+ "post-event",
+ "Observed event",
+ "body",
+ observed_at="2026-08-21T03:00:00+00:00",
+ time_axis_code="event_occurred_at",
+ ),
+ ChatSourceDocument("post-unknown", "No observed instant", "body"),
+ )
+
+ assert cited_post_events(sources, ("post-unknown", "missing", "post-event")) == [
+ {
+ "post_id": "post-unknown",
+ "post_title": "No observed instant",
+ "observed_at": None,
+ "time_axis_code": None,
+ },
+ {
+ "post_id": "post-event",
+ "post_title": "Observed event",
+ "observed_at": "2026-08-21T03:00:00+00:00",
+ "time_axis_code": "event_occurred_at",
+ },
+ ]
+
+
def test_cited_post_evidence_hides_prompt_metadata_but_keeps_semantic_facts() -> None:
source = ChatSourceDocument(
"post-evidence",
@@ -356,3 +385,4 @@ def fake_post_json(url, payload, *, headers, timeout):
assert observed["payload"]["reasoning_effort"] == "auto"
assert observed["payload"]["mode"] == "auto"
assert "CITED SOURCES" in observed["payload"]["messages"][0]["content"]
+ assert "Never infer it from chronology alone" in observed["payload"]["messages"][0]["content"]
From bec013d8b520bda9671eef44900446fcdef1e929 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 18:28:20 -0700
Subject: [PATCH 073/393] fix: consolidate canonical Compose product stack
(#678)
* fix(compose): consolidate canonical product stack
* fix(smoke): avoid unrelated Rust build dependency
* docs(ops): record exact isolated stack cleanup
---------
Co-authored-by: Codex
---
ARCHITECTURE.md | 4 ++
CHANGELOG.md | 4 ++
Makefile | 2 +-
README.md | 10 +++++
docker-compose.yml | 1 +
docs/adr/0224-canonical-compose-project.md | 44 +++++++++++++++++++
docs/adr/README.md | 1 +
.../compose-project-consolidation.md | 33 ++++++++++++++
docs/product-technical-gap-baseline.md | 12 +++++
tests/test_frontend_container_contract.py | 24 ++++++++++
tests/test_makefile_contract.py | 3 +-
11 files changed, 136 insertions(+), 2 deletions(-)
create mode 100644 docs/adr/0224-canonical-compose-project.md
create mode 100644 docs/operability/compose-project-consolidation.md
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 6788714fa..290e7697e 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -161,6 +161,10 @@ real-provider LLM tests).
provider (`docker/keycloak/realm-export.json` seeds a `lineageweave-demo`
realm with synthetic demo accounts carrying `corp_code` / `pu_code` as
custom token claims -- see [README](README.md#local-product-stack-docker-compose)).
+ADR 0224 fixes the default project name to `lineageweave` and keeps the
+migration, SearXNG, contextual-orchestrator, backend, and frontend in that same
+project. Test stacks use an explicit disposable `-p` name; they never replace a
+canonical service with a container built from another worktree.
`scripts/smoke_test_oidc.py` proves the round-trip is real: it logs in as
the synthetic demo user, fetches Keycloak's live JWKS, and cryptographically
verifies the returned JWT's RS256 signature rather than just checking for an
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f57bce06..561ea7b9c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@ All notable changes to this project are documented here. Format follows
### Changed
+- Docker Compose now has one canonical `lineageweave` project containing the
+ complete synthetic product stack. The backend receives the existing TEPP API
+ credential contract, and the OIDC smoke target installs its declared backend
+ dependencies before verifying the live Keycloak signature.
- Rankings now call RankWeave's parameter-free classic RRF path when no
calibrated weights exist and project RankWeave-owned channel contributions;
the prior local contribution arithmetic and invalid all-ones call to the
diff --git a/Makefile b/Makefile
index 62e1b3198..c892f6f91 100644
--- a/Makefile
+++ b/Makefile
@@ -21,7 +21,7 @@ ps:
# Keycloak's live JWKS, and asserts the corp_code/pu_code claims. See
# scripts/smoke_test_oidc.py.
smoke:
- uv run --locked python scripts/smoke_test_oidc.py
+ uv run --locked --extra dev python scripts/smoke_test_oidc.py
# Seeds synthetic corp/account/post rows keyed to the actual Keycloak demo
# users' real subject ids, plus Valkey ticket_created events so Activity
diff --git a/README.md b/README.md
index 22633f79d..0a12d98ff 100644
--- a/README.md
+++ b/README.md
@@ -123,6 +123,16 @@ compatibility aliases only. `ORCHESTRATOR_BASE_URL` and
`ORCHESTRATOR_API_KEY` are separate, internal
LineageWeave-to-orchestrator settings.
+The Compose file declares `lineageweave` as its canonical default project, so
+the same eight-service synthetic stack is addressed from the repository and
+temporary worktrees. Isolated tests may override it explicitly with `-p`; use
+`docker compose down` without `-v` when retiring such a project so its named
+volumes remain recoverable (ADR 0224).
+The bundled Keycloak realm is the standalone/local/dev/test fallback only.
+Setting `KEYVERSE_ISSUER` selects central Keyverse for both backend and
+frontend and activates the fail-closed claim binding in ADR 0156; the two
+issuers are never combined as authorization authorities.
+
Postgres and Keycloak are built (`docker/postgres-init/`, `docker/keycloak/`)
rather than bind-mounted, so the keycloak database's init script and the
realm seed ship inside the images themselves -- portable to any Docker host
diff --git a/docker-compose.yml b/docker-compose.yml
index 924bd3092..6729174f6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -177,6 +177,7 @@ services:
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-${CONTEXTUAL_ORCHESTRATOR_TOKEN:-lineageweave-orchestrator-dev-only}}
SEARXNG_BASE_URL: http://searxng:8080
TEPP_TRANSPORT_URL: ${TEPP_TRANSPORT_URL:-}
+ TEPP_API_KEY: ${TEPP_API_KEY:-}
CALDAV_BASE_URL: ${CALDAV_BASE_URL:-}
NARUON_CALENDAR_BASE_URL: ${NARUON_CALENDAR_BASE_URL:-}
NARUON_CALENDAR_SERVICE_TOKEN: ${NARUON_CALENDAR_SERVICE_TOKEN:-}
diff --git a/docs/adr/0224-canonical-compose-project.md b/docs/adr/0224-canonical-compose-project.md
new file mode 100644
index 000000000..2c722c324
--- /dev/null
+++ b/docs/adr/0224-canonical-compose-project.md
@@ -0,0 +1,44 @@
+# ADR 0224: Canonical local Compose project
+
+- Status: Accepted
+- Date: 2026-08-26
+
+## Context
+
+Running the same Compose file from temporary worktrees created multiple `lw*`
+and branch-named projects. Operators could no longer tell which stack owned the
+current synthetic database, migrations, frontend, backend, identity provider,
+search, queue, and contextual-orchestrator boundary. One observed project also
+carried `TEPP_API_KEY` into the backend while the Dashboard candidate omitted
+that already-supported runtime setting.
+
+## Decision
+
+`docker-compose.yml` declares the default project name `lineageweave` and keeps
+all eight product services in that project: PostgreSQL, the one-shot migration,
+Valkey, SearXNG, Keycloak, contextual-orchestrator, backend, and frontend.
+An isolated test may still override the name explicitly with Compose `-p`; it
+must use a disposable name and must not mutate the canonical project.
+
+The backend receives only its TEPP transport URL and TEPP API credential. The
+provider gateway credentials remain confined to contextual-orchestrator through
+the existing `${HOME}/.env` boundary. Compose cleanup uses `docker compose down`
+for an exactly identified project and never deletes named volumes by default.
+
+Identity selection remains ADR 0028/0156's exclusive choice. With a non-empty
+`KEYVERSE_ISSUER`, backend and frontend use central Keyverse and malformed or
+unbound Keyverse scope claims fail closed; the local Keycloak service is not a
+second trusted issuer. With no Keyverse issuer, standalone/local/dev/test uses
+only the synthetic `lineageweave-demo` Keycloak realm.
+
+## Consequences
+
+- `make up`, `make ps`, `make logs`, and `make down` address the same project
+ from the repository or a worktree unless an isolated test explicitly uses
+ `-p`.
+- A complete synthetic acceptance run can exercise OIDC, migrations, search,
+ Valkey, contextual-orchestrator, backend, frontend, Dashboard, and Ask without
+ mixing services from different working directories.
+- Historical `lw*` projects may be removed only after comparing their Compose
+ source and validating the canonical stack; their named volumes remain
+ recoverable.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index fc75367d0..9f3391bc6 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -19,6 +19,7 @@ decision from them.
| [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md) |
| [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md) |
| [`operability/http-concurrency-evidence.md`](../operability/http-concurrency-evidence.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0212](0212-single-query-authorized-post-filter-options.md) |
+| [`operability/compose-project-consolidation.md`](../operability/compose-project-consolidation.md) | [0224](0224-canonical-compose-project.md) |
| Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) |
| [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) |
| [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) |
diff --git a/docs/operability/compose-project-consolidation.md b/docs/operability/compose-project-consolidation.md
new file mode 100644
index 000000000..e9d47328a
--- /dev/null
+++ b/docs/operability/compose-project-consolidation.md
@@ -0,0 +1,33 @@
+# Local Compose project consolidation evidence
+
+On 2026-08-26 KST, container labels were read before any cleanup. Three
+non-canonical Compose projects were identified by exact project name and
+configuration path: `lw-cancelled-visualk5c5lp`, `lw-k6-agent`, and `lwrepro`.
+Their service definitions were compared with the Dashboard candidate. The only
+still-supported environment contract absent from that candidate was
+`TEPP_API_KEY`; it is now part of the canonical backend service.
+
+Before cleanup, `lineageweave-dashboard-metrics` ran PostgreSQL plus its
+successful one-shot migration, Valkey, SearXNG, Keycloak,
+contextual-orchestrator, backend, and frontend. Live OIDC/JWKS verification
+passed. An authenticated 2-VU, 20-second k6 run completed 162 requests with
+zero failures across posts, Event Lineage, Dashboard, and Ask polling; all
+seven observed Ask jobs in the synthetic database were `succeeded`.
+This local run left `KEYVERSE_ISSUER` unset and therefore proved the synthetic
+Keycloak fallback only. A Keyverse-configured deployment is a separate,
+fail-closed issuer and claim-binding acceptance boundary under ADR 0028/0156.
+
+Each identified Compose project was retired with its exact `-p` project name
+and `docker compose down`, without `-v`. Six named volumes remain: one
+PostgreSQL and one Valkey volume for each retired project. The independently
+created `lw-orch-hostport` container has no Compose project/configuration
+labels, so it was not guessed into a project or deleted. A later exact-label
+audit found one running `lw-k6-agent` migration container that Compose could
+not discover because it lacked configuration labels; after its project and
+service labels were revalidated, that isolated test container was removed
+directly. Stale created-only projects `lineageweave-kg-fix-20260822` and
+`lineageweave-261-exact` were also removed with their exact project names.
+Named volumes were not deleted.
+
+This is local, synthetic runtime evidence. It is neither production capacity
+evidence nor protected-main delivery evidence.
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index ace1e0ffa..7629bc1ad 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -7,6 +7,18 @@
## Operations Dashboard PRD/TRD traceability
+ADR 0224 reconciles the observed `lw*` test projects with the complete
+`lineageweave` Compose boundary. The Dashboard candidate stack exercised all
+eight declared services with 27 synthetic posts: live OIDC/JWKS verification
+passed, the latest topic-coordinate migration tables were present, Ask reached
+`succeeded`, and a 2-VU 20-second authenticated k6 observation completed 162
+HTTP requests with zero failures. This is local candidate evidence, not a
+protected-main release claim. Historical test projects are retired only by
+their exact Compose project label and without named-volume deletion. PR #678
+implementation head `da98de07` fixes the default project name; its follow-up
+exact-label audit also removed the remaining identifiable isolated test
+containers while preserving named volumes.
+
| Requirement | Evidence contract | Delivery state |
|---|---|---|
| Claim cause delay: order, specification change, originating order, sales pool, Event/post counts | ADR 0206; contextual-orchestrator case classification and `claim_received` → `cause_confirmed` milestones with cited spans and observed source clocks | Stacked candidate reports open/resolved/evidence-missing counts and exact elapsed time only for paired observed endpoints; every required answer and endpoint is cited or explicitly missing; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending |
diff --git a/tests/test_frontend_container_contract.py b/tests/test_frontend_container_contract.py
index 546198405..fe35f80c7 100644
--- a/tests/test_frontend_container_contract.py
+++ b/tests/test_frontend_container_contract.py
@@ -26,3 +26,27 @@ def test_compose_and_frontend_image_share_vite_build_arguments() -> None:
assert f"{variable}:" in compose
assert f"ARG {variable}" in dockerfile
assert f"{variable}=${{{variable}}}" in dockerfile
+
+
+def test_compose_has_one_default_project_and_complete_service_boundary() -> None:
+ """Default Compose runs share one name and expose every owned integration hop."""
+ compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
+
+ assert compose.startswith("name: lineageweave\n")
+ for service in (
+ "postgres",
+ "database_migration",
+ "valkey",
+ "searxng",
+ "keycloak",
+ "orchestrator",
+ "backend",
+ "frontend",
+ ):
+ assert f" {service}:\n" in compose
+ assert "TEPP_API_KEY: ${TEPP_API_KEY:-}" in compose
+ assert "KEYVERSE_ISSUER: ${KEYVERSE_ISSUER:-}" in compose
+ assert (
+ "VITE_KEYVERSE_ISSUER: "
+ "${KEYVERSE_ISSUER:-http://localhost:${KEYCLOAK_PORT:-18080}/realms/lineageweave-demo}"
+ ) in compose
diff --git a/tests/test_makefile_contract.py b/tests/test_makefile_contract.py
index 5dff9a3b5..9b97f9637 100644
--- a/tests/test_makefile_contract.py
+++ b/tests/test_makefile_contract.py
@@ -10,7 +10,8 @@ def test_makefile_runtime_targets_use_locked_uv_environment() -> None:
encoding="utf-8"
)
- assert "uv run --locked python scripts/smoke_test_oidc.py" in makefile
+ assert "uv run --locked --extra dev python scripts/smoke_test_oidc.py" in makefile
+ assert "--extra backend python scripts/smoke_test_oidc.py" not in makefile
assert "uv run --locked python scripts/seed_demo_data.py" in makefile
assert "\n\tpython3 scripts/smoke_test_oidc.py" not in makefile
assert "\n\tpython3 scripts/seed_demo_data.py" not in makefile
From dae28fb2b70df1fb98ff9d20ba51a919a69abf86 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 18:28:24 -0700
Subject: [PATCH 074/393] build: pin fail-closed lineage weight owner contract
(#681)
* build: pin lineage weight owner contract
* build: refresh lineage owner exact head
---------
Co-authored-by: Codex
---
.../0208-externalize-local-mathematical-compute.md | 8 ++++++++
docs/product-technical-gap-baseline.md | 2 +-
pyproject.toml | 11 +++++------
uv.lock | 4 ++--
4 files changed, 16 insertions(+), 9 deletions(-)
diff --git a/docs/adr/0208-externalize-local-mathematical-compute.md b/docs/adr/0208-externalize-local-mathematical-compute.md
index 1c5d9adda..d0a2c6e5a 100644
--- a/docs/adr/0208-externalize-local-mathematical-compute.md
+++ b/docs/adr/0208-externalize-local-mathematical-compute.md
@@ -71,6 +71,14 @@ different responsibility.
## Implemented migration slices
+- The backend dependency is immutably pinned to fast-mlsirm commit
+ `b3d85c35856fa8f7376821084f93292d0dea0407`, which publishes the strict
+ `tepp.lineage_pair_criterion_posterior.v2` minimum producer schema and keeps
+ channel-weight estimation unavailable. This is a contract adapter only: the
+ legacy Python estimator remains frozen migration debt and MUST NOT be used to
+ activate calibrated weights. No consumer-facing projection exposes schema,
+ transport, hash, TEPP, or fast-mlsirm internals.
+
- The residual interaction map consumes fast-mlsirm's protected-main
`residual_interaction_map` and `polytomous_expected_response` contracts.
Gabriel SVD, axis inertia, distance, reconstruction, unexplained residual,
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 7629bc1ad..3cba8a3e3 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -452,7 +452,7 @@ this file per §3.5 of the prior snapshot).
| SKOS organization aliases | Catalog binding and chip caption live on #480 / #482 | One catalog row per corroborated org; companion caption is hint-only until bound |
| Event Lineage evidence | Channel evidence and Allen relations live on #387 / #484 | Persist channel scores, explain them in the popup, never invent a fused score |
| Scientific measurement | Durable accepted TEPP receipts and LineageWeave #614's exact accepted snapshot/cutoff/run/pair-count consumer are protected; TEPP #237 remains open, so no registered producer artifact exists yet. #387 removes inferred/default persistence weights, but several older reconstruction tests still pass hand-authored numeric dictionaries that are not estimator evidence | Land TEPP #237 through its protected gate, then replace remaining reconstruction-test constants with provenance-bearing fast-mlsirm estimates over synthetic fixtures. Retain true-parameter RMSE recovery as the acceptance bar |
-| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stack also removes local RRF contribution arithmetic and corrects the invalid all-ones call to RankWeave's convex API through RankWeave #47 (`f92c3a2c`), but RankWeave #47 is still Python and therefore is not the final Rust CPU/GPU execution contract. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Land the owner and stacked consumer through protected gates; then move RankWeave's calculation core to Rust CPU/GPU and migrate each remaining construct to its owning repository contract before requiring an empty transition inventory |
+| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stack also removes local RRF contribution arithmetic and corrects the invalid all-ones call to RankWeave's convex API through RankWeave #47 (`f92c3a2c`), but RankWeave #47 is still Python and therefore is not the final Rust CPU/GPU execution contract. The backend now immutably pins fast-mlsirm owner PR #1423 head `b3d85c35856fa8f7376821084f93292d0dea0407`, which validates the accepted-anchor prerequisite and publishes the exact pair-level posterior producer schema while returning unavailable instead of inventing weights. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Obtain independent pair-level criterion posterior evidence with TDT/CHRONOS event-time provenance and CPU/GPU parity receipts; only then land the Rust estimator and delete the frozen Python channel-weight path. Continue the separate RankWeave and period-report owner migrations before requiring an empty transition inventory |
| Asynchronous authorization | Protected `main` rebuilds Global Ask worker scope after the bearer token leaves the request; #468 now persists exact Keyverse organization/process-unit scope in 3NF child tables and intersects it with current affiliations | Land #468 through the protected gate; prove a second affiliation and a revoked process unit cannot widen delayed-job evidence |
| Planned-facility intent | Planned-facility relationship intent remains only on closed, unmerged #490; earlier stack-only merges were not protected delivery | Recreate the evidence-backed slice on a current base and land through protected `main` before a release claim |
| Accessibility and responsive UX | #602 delivered base post-detail modal semantics; #605 adds selected-post refocus, collapsed/hidden/inert/CSS-invisible focus exclusion across both modal types, readable evidence separators, focused tests, and desktop/mobile Storybook screenshots | Land #605 through the protected gate, then complete screen-reader and authenticated Playwright acceptance on the exact release head |
diff --git a/pyproject.toml b/pyproject.toml
index 110858e16..2a55f69b9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -51,12 +51,11 @@ backend = [
# Speaks RESP; works against Valkey (a Redis-protocol-compatible fork)
# as well as real Redis. Used for the post-activity event stream.
"redis>=5.0.0",
- # LLM-as-a-Judge -> IRT -> Fixed-Item Parameter Calibration for the
- # weekly/monthly PU/team/project reports (ADR 0003). No PyPI release
- # yet; pinned to a specific commit, same pattern as rankweave. Ships a
- # PyO3/maturin Rust core with no fallback wheel, so building this from
- # source needs a Rust toolchain -- see backend/Dockerfile.
- "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@256470c7d1df4910a018841499a74d88b751a774",
+ # Owner arithmetic plus the fail-closed TEPP lineage criterion consumer
+ # contract (ADR 0208). The exact commit is immutable; it still exposes no
+ # channel-weight result until independent pair-level posterior evidence is
+ # available. PyO3/maturin source builds need the pinned Rust toolchain.
+ "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@b3d85c35856fa8f7376821084f93292d0dea0407",
]
[tool.setuptools.packages.find]
diff --git a/uv.lock b/uv.lock
index 2225d1d16..be5f249f0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -471,7 +471,7 @@ wheels = [
[[package]]
name = "fast-mlsirm"
version = "0.9.0"
-source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=256470c7d1df4910a018841499a74d88b751a774#256470c7d1df4910a018841499a74d88b751a774" }
+source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=b3d85c35856fa8f7376821084f93292d0dea0407#b3d85c35856fa8f7376821084f93292d0dea0407" }
dependencies = [
{ name = "numpy" },
]
@@ -655,7 +655,7 @@ requires-dist = [
{ name = "certifi", specifier = ">=2024.0.0" },
{ name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" },
{ name = "cryptography", specifier = ">=42.0" },
- { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=256470c7d1df4910a018841499a74d88b751a774" },
+ { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=b3d85c35856fa8f7376821084f93292d0dea0407" },
{ name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.141.1" },
{ name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.12.0" },
{ name = "opentelemetry-api", specifier = ">=1.30.0" },
From cb78cd7808fc38d2d709ff27e7dd4b3ee201f037 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 10:37:40 +0900
Subject: [PATCH 075/393] docs(adr): define portable Rust accelerator backends
---
...ative-mlx-mathematical-compute-boundary.md | 175 ++++++++++++++++++
docs/adr/README.md | 1 +
docs/product-technical-gap-baseline.md | 1 +
3 files changed, 177 insertions(+)
create mode 100644 docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md
diff --git a/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md b/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md
new file mode 100644
index 000000000..e696aab83
--- /dev/null
+++ b/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md
@@ -0,0 +1,175 @@
+# ADR 0226: macOS-native MLX boundary for Rust-owned computation
+
+- Status: Accepted
+- Date: 2026-08-26
+- Amends: ADR 0208 and ADR 0210
+- Clarifies: ADR 0076
+
+## Context
+
+LineageWeave runs its product services in Linux containers through Docker or
+Colima on Apple Silicon. The MLX Metal backend is not a Linux-container
+capability. MLX enables Metal on Darwin and requires Apple Silicon, macOS 14,
+Xcode 15, and the macOS 14 SDK; its Linux distributions provide CPU or NVIDIA
+CUDA backends instead. A Colima Linux VM therefore cannot truthfully issue an
+MLX Metal execution receipt merely because its macOS host has an Apple GPU.
+
+ADR 0208 already assigns mathematical and psychometric arithmetic to the Rust
+cores of TEPP, fast-mlsirm, and RankWeave. Moving a formula into Python to gain
+access to MLX would violate that ownership boundary. ADR 0076's prohibition on
+LineageWeave-specific MLX model-provider routes remains unchanged: this ADR is
+about owner-repository numerical kernels, not LLM or VISION routing.
+
+## Decision
+
+1. On Apple Silicon, an owner repository may execute an accepted numerical
+ kernel through MLX Metal only in a **macOS-native process**. The owner Rust
+ core remains the algorithm and contract authority and links the MLX C/C++
+ surface or an equally typed native FFI boundary. Python may launch or marshal
+ a generated binding, but it may not implement, transform, normalize, score,
+ or repair the mathematics.
+2. Linux Compose containers never claim MLX Metal execution. They call the
+ macOS-native owner service through an explicitly configured authenticated
+ HTTPS boundary reachable from the container host gateway. No endpoint,
+ credential, or certificate is baked into an image or committed. Mutual TLS
+ is required; the native service binds only to the local host interface and
+ authorizes the exact owner contract and tenant scope.
+3. The transport uses a versioned request/result envelope with input and output
+ SHA-256 digests, owner code revision, estimand and schema versions, device
+ identity, backend (`mlx_metal`, `mlx_cpu`, `mlx_cuda`, or `rust_cpu`),
+ precision, worker configuration, start/end instants, convergence and
+ identification diagnostics, and a signed execution receipt. A requested
+ Metal run without an `mlx_metal` receipt fails closed.
+4. Linux CI and non-Apple deployments may execute an owner-approved MLX CPU,
+ MLX CUDA, deterministic multithreaded Rust CPU, or owner-native Rust OpenCL
+ path. MLX does not publish an OpenCL backend, so an OpenCL receipt MUST be
+ `rust_opencl`, never `mlx_opencl`. The caller requests one exact capability;
+ runtime discovery cannot silently choose another backend. CPU portability
+ is not evidence that Metal, CUDA, or OpenCL was exercised.
+5. Every newly accelerated estimand requires deterministic synthetic recovery,
+ Rust-reference versus MLX numerical parity with the estimand's
+ identification constraints, non-finite and shape rejection, device-receipt
+ verification, disconnect/timeout/idempotency tests, and an actual
+ Apple-Silicon Metal integration run. A tolerance must come from the owner's
+ numerical error analysis and precision contract; no local constant is
+ invented by LineageWeave.
+6. LineageWeave remains a consumer. It may persist and authorize an accepted
+ receipt but never selects an MLX device, retries on a different mathematical
+ backend, or recomputes a rejected result. Customer UI presents the measured
+ result, uncertainty, evidence, and next action; it does not expose MLX,
+ Colima, FFI, transport, schema, or package details.
+7. Deployment is fail-closed and reversible. If the native service is absent,
+ untrusted, incompatible, or produces a parity-invalid result, the affected
+ channel is unavailable and dropped under the existing renormalization
+ contract. Rollback disables the native endpoint and returns to an already
+ accepted owner CPU contract; it never substitutes Python arithmetic.
+
+## Docker Compose backend contract
+
+Owner repositories publish four additive, versioned Compose overlays. The
+base product Compose file contains no accelerator device and remains the CPU-
+portable control plane. A deployment selects exactly one overlay and records
+its rendered Compose digest in the execution receipt.
+
+| Requested backend | Where computation runs | Compose/device contract | Required proof before accepting work |
+|---|---|---|---|
+| `rust_cpu` or `mlx_cpu` | Linux owner-service container | `compose.compute-cpu.yml`; no host device mapping | container CPU architecture, owner self-test, worker-count determinism, memory limit and actual backend receipt |
+| `mlx_cuda` | Linux owner-service container on an NVIDIA host | `compose.compute-cuda.yml`; Docker device reservation with `driver: nvidia`, either an explicit `device_ids` list or measured `count` (never both), and mandatory `capabilities: [gpu]` | NVIDIA driver/toolkit and MLX CUDA compatibility, selected device identity, a real CUDA kernel self-test, CPU/CUDA parity |
+| `rust_opencl` | Linux owner-service container | `compose.compute-opencl.yml`; a vendor CDI device is preferred. If CDI is unavailable, map only preflight-discovered render/compute nodes and mount the matching vendor ICD read-only; never map all of `/dev` or grant privileged mode | OpenCL platform/device identity, ICD and kernel availability, a real OpenCL kernel self-test, CPU/OpenCL parity |
+| `mlx_metal` | macOS-native Rust owner service outside Colima | no GPU device in Compose. `compose.compute-metal-host.yml` supplies only the opaque mTLS endpoint and certificate-file mounts from runtime secrets | native arm64/macOS/SDK compatibility, Metal device identity, signed native-service health, a real MLX Metal kernel self-test, CPU/Metal parity |
+
+The deployment procedure is normative:
+
+1. Run the owner-supplied preflight in **plan mode**. It reads the container
+ CPU/memory limits and enumerates only APIs available on that platform
+ (MLX device query, NVIDIA management API, OpenCL ICD, or macOS Metal). It
+ emits a machine-readable plan containing the requested backend, exact
+ device identity, driver/runtime versions, resource limits, overlay digest,
+ and failed prerequisites. It does not mutate Docker or select a fallback.
+2. Reject the plan unless the requested backend and every prerequisite are
+ satisfied. Device selection comes from an explicit administrator choice or
+ the only compatible discovered device; multiple compatible devices require
+ an explicit choice rather than catalog-order selection.
+3. Validate the rendered configuration with
+ `docker compose -f docker-compose.yml -f compose.compute-.yml config
+ --quiet`. The macOS native service must already be healthy before the Metal
+ host overlay is admitted.
+4. Start with the same files and canonical project name:
+ `docker compose -f docker-compose.yml -f
+ compose.compute-.yml -p lineageweave up -d`. Secrets and mTLS
+ material enter through runtime-only files or the platform secret store,
+ never an image, Compose literal, log, or receipt.
+5. Run the owner's device self-test and numerical parity acceptance. Only then
+ mark the backend ready. Health means that the selected device executed the
+ kernel; a process-level HTTP 200 is insufficient.
+6. On a device, driver, receipt, parity, or connectivity failure, stop
+ accepting new mathematical jobs and surface the channel as unavailable.
+ Do not restart under CPU automatically. An authorized operator may render
+ and admit the CPU overlay as a separate deployment decision.
+7. Teardown uses the exact file set and project name with `down` and never
+ removes volumes unless separately authorized. Test-only projects use an
+ isolated project name and are removed after their evidence is retained.
+
+Raw device mappings are a portability exception, not the default. The
+generated OpenCL overlay must contain the exact preflight-discovered device
+paths; a static wildcard, privileged container, host PID namespace, or broad
+device cgroup permission is prohibited. CUDA follows Docker's device
+reservation contract. CDI is used when the Docker daemon and vendor expose a
+compatible device specification because it carries device nodes, libraries,
+environment, and hooks as one auditable declaration.
+
+## Runtime topology
+
+```mermaid
+flowchart LR
+ UI[LineageWeave UI] --> API[Linux Compose API]
+ API -->|mTLS, versioned envelope| HOST[macOS-native Rust owner service]
+ HOST -->|typed native boundary| MLX[MLX Metal]
+ HOST -->|signed result and receipt| API
+ API --> DB[(Provenance store)]
+```
+
+## Consequences
+
+- Apple GPU acceleration remains available without falsely treating a Linux
+ VM as a Metal host.
+- The native service becomes a separately supervised local component with
+ certificate rotation, health, timeout, admission, audit, and resource-limit
+ responsibilities.
+- Compose stays portable. A machine without the native capability still runs
+ the product and honestly reports the affected measurement as unavailable or
+ uses a separately accepted owner CPU/CUDA result.
+- TEPP, fast-mlsirm, and RankWeave must each adopt this boundary in their own
+ normative ADR before publishing an `mlx_metal` receipt.
+
+## Alternatives considered
+
+1. **Run MLX Metal inside Colima.** Rejected because the guest is Linux and MLX
+ disables its Metal backend there.
+2. **Move the kernel into host Python.** Rejected because it transfers
+ mathematical ownership out of Rust and duplicates formulas.
+3. **Mount an unauthenticated local socket.** Rejected because VM socket
+ forwarding is runtime-specific and an unauthenticated compute boundary can
+ cross tenant and provenance scopes.
+4. **Label any Apple-hosted run as Metal.** Rejected because host hardware does
+ not prove which backend executed the operation.
+5. **Call a Rust OpenCL kernel MLX.** Rejected because MLX has no OpenCL
+ backend; backend identity is measurement provenance, not branding.
+
+## References (APA 7th)
+
+Hannun, A., Digani, J., Katharopoulos, A., & Collobert, R. (2023). *MLX: An
+array framework for Apple silicon* [Computer software]. Apple Machine Learning
+Research. https://github.com/ml-explore/mlx
+
+MLX Contributors. (2026). *Build and install: MLX 0.32.1 documentation*.
+https://ml-explore.github.io/mlx/build/html/install.html
+
+MLX Contributors. (2026). *Unified memory: MLX 0.32.1 documentation*.
+https://ml-explore.github.io/mlx/build/html/usage/unified_memory.html
+
+Docker, Inc. (2026). *Run Docker Compose services with GPU access*.
+https://docs.docker.com/compose/how-tos/gpu-support/
+
+Docker, Inc. (2026). *Container Device Interface (CDI)*.
+https://docs.docker.com/build/building/cdi/
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 9f3391bc6..c70c2fb7d 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -25,6 +25,7 @@ decision from them.
| [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) |
| Canonical Docker Compose project (`name: lineageweave`) | [0224](0224-canonical-compose-project-name.md) |
| Ask answer citation and evidence-timeline interaction | [0225](0225-ask-answer-evidence-timeline.md) |
+| macOS-native Rust/MLX mathematical compute boundary | [0226](0226-macos-native-mlx-mathematical-compute-boundary.md), [0208](0208-externalize-local-mathematical-compute.md) |
[0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/).
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 3cba8a3e3..24ff4c85b 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -25,6 +25,7 @@ containers while preserving named volumes.
| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending |
| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated synthetic runtime passed with the honest zero-result state |
| Project-specific journey | Explicit source/semantic project membership plus provenance-bearing TEPP TDT/CHRONOS predecessor, branch, and transition results | Candidate API preserves every explicit project membership, but its local event-time sort is only an observed-event list. It is no longer labeled as a Project Journey. Full journey delivery remains open until the accepted TEPP producer artifact is persisted and rendered; no fixed sales/order start or nearest-date edge is accepted. |
+| Apple-Silicon mathematical acceleration | ADR 0226 macOS-native Rust owner service with authenticated MLX Metal execution receipts | Normative boundary accepted; TEPP, fast-mlsirm, and RankWeave owner implementations and actual Metal parity receipts remain required before activation |
| Ask answer citation-to-event navigation | ADR 0225; authorized cited source, observed source clock, focused evidence layer, and full-post navigation | Stacked candidate renders numbered answer citations and chronologically ordered evidence cards with bidirectional focus. Event and record clocks stay distinct; this list does not claim a Project Journey. Storybook and component interaction evidence are included; authenticated runtime screenshots remain required at the exact candidate head. |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending |
From 6272197a20c9938fa35b802f0538fbab1361b38c Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 10:41:28 +0900
Subject: [PATCH 076/393] docs(adr): consolidate Compose project decision
---
.../0224-canonical-compose-project-name.md | 39 -------------------
docs/adr/README.md | 2 +-
2 files changed, 1 insertion(+), 40 deletions(-)
delete mode 100644 docs/adr/0224-canonical-compose-project-name.md
diff --git a/docs/adr/0224-canonical-compose-project-name.md b/docs/adr/0224-canonical-compose-project-name.md
deleted file mode 100644
index c906e2124..000000000
--- a/docs/adr/0224-canonical-compose-project-name.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# ADR 0224: Pin the canonical Compose project name
-
-## Status
-
-Accepted — 2026-08-26
-
-## Context
-
-Docker Compose otherwise derives its project name from the checkout directory.
-Stacked PR worktrees therefore produced several production-looking
-`lineageweave-*` projects with duplicated networks and stateful services. That
-made the product preview ambiguous and could point operators at the wrong
-PostgreSQL or identity service.
-
-## Decision
-
-The repository Compose file declares `name: lineageweave`. Normal `docker
-compose` commands therefore converge on one canonical standalone stack,
-regardless of the checkout directory name.
-
-An isolated test or review environment may override the name explicitly with
-`docker compose -p `. Such an override is test infrastructure,
-not another canonical deployment. After its declared objective succeeds, its
-evidence and any required behavior are retained, then its containers and
-network are removed with `docker compose -p down`. Named
-volumes are never deleted as part of project-name consolidation without a
-separate, explicit authorization.
-
-The canonical standalone stack retains its synthetic local Keycloak fallback.
-Organization-integrated deployments configure the central Keyverse issuer as
-required by ADR 0028 and ADR 0156; the Compose project name does not change the
-identity-provider trust boundary.
-
-## Consequences
-
-- `docker compose up` consistently creates the `lineageweave` project.
-- Preview and operational instructions have one unambiguous project name.
-- Parallel review stacks must opt into a distinct name and ports deliberately.
-- Existing noncanonical volumes remain recoverable until separately retired.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index c70c2fb7d..b12fc90b4 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -23,7 +23,7 @@ decision from them.
| Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) |
| [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) |
| [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) |
-| Canonical Docker Compose project (`name: lineageweave`) | [0224](0224-canonical-compose-project-name.md) |
+| Canonical Docker Compose project (`name: lineageweave`) | [0224](0224-canonical-compose-project.md) |
| Ask answer citation and evidence-timeline interaction | [0225](0225-ask-answer-evidence-timeline.md) |
| macOS-native Rust/MLX mathematical compute boundary | [0226](0226-macos-native-mlx-mathematical-compute-boundary.md), [0208](0208-externalize-local-mathematical-compute.md) |
From a357fbfcabb5719395a3bb40a407299d9d68b3f8 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 10:46:57 +0900
Subject: [PATCH 077/393] fix(seed): respect source evidence eligibility
---
scripts/seed_demo_data.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index 092d28fa5..4b1c46d92 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -31,6 +31,7 @@
import psycopg2
+from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from lineageweave.http_client import get_json, get_json_list, post_form
from lineageweave.post_summary import ACTOR_TYPE_PERSON, POST_SUMMARY_CONTRACT_VERSION
from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable
@@ -2211,7 +2212,9 @@ def _warm_seeded_post_content(
try:
with connection.cursor() as cur:
cur.execute(
- "select post_id from source_post where post_title like 'Demo %post'"
+ "select post_id from source_post "
+ "where post_title like 'Demo %post' and "
+ + SOURCE_POST_ELIGIBILITY_SQL.format(alias="source_post")
)
post_ids = [str(row[0]) for row in cur.fetchall()]
finally:
From 72703c31fec69cf45e51451036d38c33566d989a Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 10:50:05 +0900
Subject: [PATCH 078/393] fix(orchestrator): pin embedding capability selection
---
docker/contextual-orchestrator/Dockerfile | 2 +-
docs/adr/0083-orchestrator-runtime-commit-pin.md | 3 ++-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 0af60f58c..c14885083 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/315c9f05834df6df3a946de160db96f22c355e8b.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 1ad14cade..5bfbef271 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,8 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89`. The pin remains explicit
+commit `315c9f05834df6df3a946de160db96f22c355e8b`, the merge commit for
+contextual-orchestrator PR 789's embedding-capability selection contract. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index b287d3470..bb5bedfbc 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89"
+ expected_embedding_contract_commit = "315c9f05834df6df3a946de160db96f22c355e8b"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 4a4acc38382c3ffb22dbf5092542ee454d4e30f5 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 10:53:10 +0900
Subject: [PATCH 079/393] fix: close customer-copy and migration review
findings
---
.../test_operations_dashboard_postgres.py | 1 +
docs/adr/0024-rankweave-fusion-fail-closed.md | 5 +++--
frontend/src/App.css | 2 +-
.../src/components/AskAnswerTimeline.test.tsx | 13 +++++++++++++
frontend/src/components/AskAnswerTimeline.tsx | 13 +++++++++----
.../components/OperationsDashboard.test.tsx | 8 ++++++++
.../src/components/OperationsDashboard.tsx | 7 ++++++-
lineageweave/leftover_pairs.py | 4 ++--
...0214_topic_context_influence_projection.sql | 18 ++++++++++++++++++
pyproject.toml | 2 +-
tests/test_schema.py | 2 +-
11 files changed, 63 insertions(+), 12 deletions(-)
diff --git a/backend/tests/test_operations_dashboard_postgres.py b/backend/tests/test_operations_dashboard_postgres.py
index fad533606..2c987979b 100644
--- a/backend/tests/test_operations_dashboard_postgres.py
+++ b/backend/tests/test_operations_dashboard_postgres.py
@@ -30,6 +30,7 @@ async def test_operations_dashboard_sql_binds_against_postgres() -> None:
"operations_case_milestone",
"operations_case_missing_milestone",
"topic_context_membership",
+ "topic_activity_interval",
"topic_post_context_influence",
)
for table_name in required_tables:
diff --git a/docs/adr/0024-rankweave-fusion-fail-closed.md b/docs/adr/0024-rankweave-fusion-fail-closed.md
index 15c875eee..184bfe31b 100644
--- a/docs/adr/0024-rankweave-fusion-fail-closed.md
+++ b/docs/adr/0024-rankweave-fusion-fail-closed.md
@@ -22,8 +22,9 @@ tables, and does not bind the demo IdP to production Keyverse.
1. Consume RankWeave only through `RankWeaveClient`. The default
transport raises `RankWeaveNotAvailable`. `build_rankweave_client
(disabled=False)` uses `LibraryRankWeaveTransport`, which imports
- `weighted_reciprocal_rank_fuse` inside the call so a missing
- package fail-closes.
+ both `reciprocal_rank_fuse` (the default parameter-free path) and
+ `weighted_reciprocal_rank_fuse` (the explicit weighted path) inside
+ the call so a missing package fail-closes.
2. `GET /api/rankings` (`post_read`) loads ABAC-visible posts as two
rank-only channels: temporal (newest first) and lexical (token
overlap with the synthetic demo query `pricing quote delivery`).
diff --git a/frontend/src/App.css b/frontend/src/App.css
index c9c56120d..f9bd2ee0b 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1104,7 +1104,7 @@
min-width: var(--size-control-min);
min-height: var(--size-control-min);
border: 0;
- border-bottom: 2px solid currentColor;
+ border-bottom: 2px solid currentcolor;
background: transparent;
color: var(--color-accent);
cursor: pointer;
diff --git a/frontend/src/components/AskAnswerTimeline.test.tsx b/frontend/src/components/AskAnswerTimeline.test.tsx
index 9f0855f2e..1465ad9b7 100644
--- a/frontend/src/components/AskAnswerTimeline.test.tsx
+++ b/frontend/src/components/AskAnswerTimeline.test.tsx
@@ -91,6 +91,7 @@ describe("AskAnswerTimeline", () => {
...answer,
cited_events: [{ ...answer.cited_events![0], observed_at: null, time_axis_code: null }],
cited_posts: [answer.cited_posts![0]],
+ cited_post_ids: [answer.cited_posts![0].post_id],
}}
onOpenEvidence={() => undefined}
onOpenPost={() => undefined}
@@ -99,4 +100,16 @@ describe("AskAnswerTimeline", () => {
expect(screen.getByText("Observed time unavailable")).toBeInTheDocument();
});
+
+ it("keeps an id-only citation visible when details are unavailable", () => {
+ render(
+ undefined}
+ onOpenPost={() => undefined}
+ />,
+ );
+ expect(screen.getByRole("article", { name: "Evidence 1: Record details" })).toBeInTheDocument();
+ });
});
diff --git a/frontend/src/components/AskAnswerTimeline.tsx b/frontend/src/components/AskAnswerTimeline.tsx
index 58bc26dfd..6da8678d7 100644
--- a/frontend/src/components/AskAnswerTimeline.tsx
+++ b/frontend/src/components/AskAnswerTimeline.tsx
@@ -45,11 +45,16 @@ export function AskAnswerTimeline({ question, answer, onOpenEvidence, onOpenPost
const citationRefs = useRef(new Map());
const cardRefs = useRef(new Map());
const eventsByPost = new Map(answer.cited_events?.map((event) => [event.post_id, event]));
- const citations: Citation[] = (answer.cited_posts ?? []).map((post, index) => ({
+ const postDetails = new Map((answer.cited_posts ?? []).map((post) => [post.post_id, post]));
+ const citationIds = [...new Set([
+ ...(answer.cited_post_ids ?? []),
+ ...(answer.cited_posts ?? []).map((post) => post.post_id),
+ ])];
+ const citations: Citation[] = citationIds.map((postId, index) => ({
citationNumber: index + 1,
- postId: post.post_id,
- postTitle: post.post_title,
- event: eventsByPost.get(post.post_id),
+ postId,
+ postTitle: postDetails.get(postId)?.post_title ?? t("Record details"),
+ event: eventsByPost.get(postId),
}));
const chronological = [...citations].sort((left, right) => {
const leftEpoch = observedEpoch(left.event);
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index 08dc61138..6f6118e89 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -141,6 +141,14 @@ describe("OperationsDashboardView", () => {
expect(screen.queryByText(/추정 점수/)).not.toBeInTheDocument();
});
+ it("shows a separate next action when topic influence does not apply", () => {
+ const notApplicable = { ...data, topic_context: { ...data.topic_context, status_code: "not_applicable" as const, reason_code: "no_eligible_posts" } };
+ render( undefined} />);
+ expect(screen.getByText("이 기간에는 글 영향도를 계산할 대상이 없습니다.")).toBeInTheDocument();
+ expect(screen.getByText("다른 기간을 선택해 분석 가능한 글이 있는지 확인하세요.")).toBeInTheDocument();
+ expect(screen.queryByText("글 영향도를 아직 확인할 수 없습니다.")).not.toBeInTheDocument();
+ });
+
it("opens accepted exact influence evidence and retains equal values", async () => {
const onOpenPost = vi.fn();
const influence = {
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index b9882716e..6e10329ca 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -196,7 +196,12 @@ export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDa
글 영향도
시간 흐름별 Topic model influence
사업 가치가 아닌, 해당 글을 제외했을 때 Topic·조직 수준 모형이 변하는 정도입니다.
- {topicContext.status_code === "unavailable" ? (
+ {topicContext.status_code === "not_applicable" ? (
+
+
이 기간에는 글 영향도를 계산할 대상이 없습니다.
+
다른 기간을 선택해 분석 가능한 글이 있는지 확인하세요.
+
+ ) : topicContext.status_code === "unavailable" ? (
글 영향도를 아직 확인할 수 없습니다.
분석 대상 글의 사건 시점과 조직 소속을 확인한 뒤 다시 분석하세요.
diff --git a/lineageweave/leftover_pairs.py b/lineageweave/leftover_pairs.py
index 572749ffe..1254b1139 100644
--- a/lineageweave/leftover_pairs.py
+++ b/lineageweave/leftover_pairs.py
@@ -135,8 +135,8 @@ def leftover_map_coverage_from_residual(
_validate_shapes(post_ids, item_codes, matrix, expected)
result = residual_interaction_map(matrix, expected, axis_count=_LEFTOVER_MAP_AXES)
- map_posts = int(len(result.person_indices))
- map_items = int(len(result.item_indices))
+ map_posts = len(result.person_indices)
+ map_items = len(result.item_indices)
return LeftoverMapCoverage(
map_post_count=map_posts,
scored_post_count=int(result.scored_person_count),
diff --git a/migrations/0214_topic_context_influence_projection.sql b/migrations/0214_topic_context_influence_projection.sql
index ffa88b202..56452e4ba 100644
--- a/migrations/0214_topic_context_influence_projection.sql
+++ b/migrations/0214_topic_context_influence_projection.sql
@@ -158,14 +158,32 @@ create table if not exists topic_post_context_influence (
alter table topic_model_run
add column if not exists coordinate_kind_code text
check (coordinate_kind_code in ('logistic_normal_coordinate', 'plausible_value'));
+do $$
+begin
+ if exists (select 1 from topic_model_run where coordinate_kind_code is null) then
+ raise exception '0214 cannot enforce coordinate_kind_code: existing runs need producer reanalysis';
+ end if;
+end $$;
alter table topic_model_run alter column coordinate_kind_code set not null;
alter table topic_lineage_relation
add column if not exists provenance_assertion_id uuid
references provenance_assertion (assertion_id);
+do $$
+begin
+ if exists (select 1 from topic_lineage_relation where provenance_assertion_id is null) then
+ raise exception '0214 cannot enforce lineage provenance: existing relations need producer reanalysis';
+ end if;
+end $$;
alter table topic_lineage_relation alter column provenance_assertion_id set not null;
alter table topic_context_membership
add column if not exists provenance_assertion_id uuid
references provenance_assertion (assertion_id);
+do $$
+begin
+ if exists (select 1 from topic_context_membership where provenance_assertion_id is null) then
+ raise exception '0214 cannot enforce membership provenance: existing memberships need producer reanalysis';
+ end if;
+end $$;
alter table topic_context_membership alter column provenance_assertion_id set not null;
create index if not exists topic_activity_interval_time_idx
diff --git a/pyproject.toml b/pyproject.toml
index 2a55f69b9..56078316e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,7 +28,7 @@ dependencies = [
]
[build-system]
-requires = ["setuptools>=75"]
+requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project.optional-dependencies]
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 3529fd7e1..62040411b 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -354,7 +354,7 @@ def test_operations_case_milestones_reject_cross_kind_types(schema_db) -> None:
(str(post_id), str(post_id), "a" * 64),
(str(post_id),),
)
- for index, (statement, values) in enumerate(zip(invalid_statements, parameters)):
+ for index, (statement, values) in enumerate(zip(invalid_statements, parameters, strict=True)):
savepoint = f"invalid_milestone_kind_{index}"
cur.execute(f"savepoint {savepoint}")
with pytest.raises(psycopg2.errors.CheckViolation):
From f88e7c648b93ad819d4501a1616161f2d3f16271 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 10:54:41 +0900
Subject: [PATCH 080/393] Revert "fix(orchestrator): pin embedding capability
selection"
This reverts commit 72703c31fec69cf45e51451036d38c33566d989a.
---
docker/contextual-orchestrator/Dockerfile | 2 +-
docs/adr/0083-orchestrator-runtime-commit-pin.md | 3 +--
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index c14885083..0af60f58c 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/315c9f05834df6df3a946de160db96f22c355e8b.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 5bfbef271..1ad14cade 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,8 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `315c9f05834df6df3a946de160db96f22c355e8b`, the merge commit for
-contextual-orchestrator PR 789's embedding-capability selection contract. The pin remains explicit
+commit `1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index bb5bedfbc..b287d3470 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "315c9f05834df6df3a946de160db96f22c355e8b"
+ expected_embedding_contract_commit = "1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From f3b1ef13f97ac995b66357cf3466f2d5245bfaef Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 11:03:57 +0900
Subject: [PATCH 081/393] feat: enqueue bounded semantic backfill jobs
---
ARCHITECTURE.md | 8 +
backend/app/main.py | 31 +++-
backend/app/post_content_queue.py | 115 ++++++++++++++
...98-valkey-backed-post-content-ingestion.md | 16 +-
docs/product-requirements.md | 3 +
docs/product-technical-gap-baseline.md | 2 +-
scripts/queue_post_content_backfill.py | 125 ++-------------
tests/test_post_content_backfill_endpoint.py | 130 ++++++++++++++++
tests/test_post_content_queue.py | 147 ++++++++++++++++++
...test_queue_post_content_backfill_script.py | 96 ++++++++++++
tests/test_static_sql_review_contracts.py | 3 +-
11 files changed, 560 insertions(+), 116 deletions(-)
create mode 100644 tests/test_post_content_backfill_endpoint.py
create mode 100644 tests/test_queue_post_content_backfill_script.py
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 290e7697e..7d04f6daa 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -438,6 +438,14 @@ name, confirmed the events on the activity endpoint, and independently
confirmed the stream's existence and length with `valkey-cli` directly
against the `valkey` container.
+Post-content ingestion uses the same transport with a stronger durability
+boundary (ADR 0098): PostgreSQL owns each job and Valkey only wakes the worker.
+`POST /api/post-content/backfill` is a `post_admin`-gated producer for one
+1--200-row eligible page. It commits jobs before publishing, returns HTTP 202
+without running semantic providers, and reports wake-ups that the worker's
+bounded recovery sweep must republish. `FOR UPDATE SKIP LOCKED` partitions
+concurrent operator calls without a second scheduler or an in-memory task.
+
## Phase 5c: customer commitment derivation and the calendar
The brief asked for two separate-sounding things: issues auto-registered
diff --git a/backend/app/main.py b/backend/app/main.py
index 9a8ad5045..5c90eda4a 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -32,7 +32,7 @@
import redis.asyncio as redis
from fastapi import Depends, FastAPI, HTTPException, Query, status
from fastapi.middleware.cors import CORSMiddleware
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from backend.app.activity_stream import (
create_valkey_client,
@@ -131,6 +131,7 @@
persist_post_chat,
)
from backend.app.post_content_queue import (
+ enqueue_post_content_backfill,
ensure_post_content_job,
post_content_api_status,
post_content_is_complete,
@@ -802,12 +803,40 @@ class LocalePreferenceRequest(BaseModel):
preferred_locale: Literal["en", "ko", "zh", "ja", "vi"]
+class PostContentBackfillRequest(BaseModel):
+ """Bounded operator request for durable semantic-content ingestion."""
+
+ limit: int = Field(default=100, ge=1, le=200)
+
+
class CustomerHintResolveRequest(BaseModel):
"""Body of a POST /api/customer-master/resolve-hint request."""
hint_code: str
+@app.post("/api/post-content/backfill", status_code=status.HTTP_202_ACCEPTED)
+async def queue_post_content_backfill(
+ request: PostContentBackfillRequest,
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+ valkey: redis.Redis = Depends(get_valkey),
+) -> dict[str, int | bool]:
+ """Queue one bounded corpus page and return before semantic work runs."""
+ _require_post_admin(account)
+ settings = load_settings()
+ require_orchestrator_evidence = bool(
+ settings.orchestrator_base_url and settings.orchestrator_api_key
+ )
+ return await enqueue_post_content_backfill(
+ pool,
+ valkey,
+ limit=request.limit,
+ require_embedding=require_orchestrator_evidence,
+ require_structure=require_orchestrator_evidence,
+ )
+
+
@app.patch("/api/me/preferences")
async def update_me_preferences(
preference: LocalePreferenceRequest,
diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py
index 43e44e015..dfc9181d9 100644
--- a/backend/app/post_content_queue.py
+++ b/backend/app/post_content_queue.py
@@ -10,6 +10,7 @@
import asyncpg
import redis.asyncio as redis
+from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from lineageweave.observability import traced
POST_CONTENT_STREAM_KEY = "post-content-ingestion"
@@ -307,6 +308,120 @@ async def ensure_post_content_job(
)
+async def enqueue_post_content_backfill(
+ pool: asyncpg.Pool,
+ client: redis.Redis | None,
+ *,
+ limit: int,
+ require_embedding: bool,
+ require_structure: bool,
+) -> dict[str, int | bool]:
+ """Durably enqueue one bounded page of eligible incomplete source posts.
+
+ PostgreSQL is committed before Valkey is touched. A missing wake-up is
+ therefore recoverable by :func:`republish_queued_post_content_jobs` rather
+ than turning an operator request into lost work. Active and terminal jobs
+ are excluded so repeated requests neither duplicate work nor reset the
+ explicit retry boundary.
+ """
+ if not 1 <= limit <= 200:
+ raise ValueError("limit must be between 1 and 200")
+ query = f"""
+ select post.post_id, post.post_body
+ from source_post post
+ left join post_content_ingestion_job job on job.post_id = post.post_id
+ where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
+ and (job.post_id is null or job.status_code = $1)
+ and (
+ not exists (
+ select 1 from post_content_unit unit
+ where unit.post_id = post.post_id
+ )
+ or ($2::boolean and exists (
+ select 1
+ from post_content_unit unit
+ left join post_content_embedding embedding
+ on embedding.post_content_unit_id = unit.post_content_unit_id
+ where unit.post_id = post.post_id
+ and embedding.post_content_embedding_id is null
+ ))
+ or ($2::boolean and exists (
+ select 1
+ from post_content_unit unit
+ join post_content_image image
+ on image.post_content_unit_id = unit.post_content_unit_id
+ join post_content_image_region region
+ on region.post_content_image_id = image.post_content_image_id
+ left join post_content_image_region_embedding embedding
+ on embedding.post_content_image_region_id = region.post_content_image_region_id
+ where unit.post_id = post.post_id
+ and region.description_status_code = 'described'
+ and embedding.post_content_image_region_embedding_id is null
+ ))
+ or ($3::boolean and exists (
+ select 1
+ from post_content_unit unit
+ left join post_content_unit_structure structure
+ on structure.post_content_unit_id = unit.post_content_unit_id
+ where unit.post_id = post.post_id
+ and unit.unit_kind_code <> 'image'
+ and (
+ structure.post_content_unit_structure_id is null
+ or structure.decision_source_code = 'unresolved'
+ )
+ ))
+ )
+ order by post.created_at, post.post_id
+ limit $4
+ for update of post skip locked
+ """
+ requests: list[PostContentJobRequest] = []
+ has_more = False
+ async with pool.acquire() as conn:
+ async with conn.transaction():
+ # Safe SQL: the eligibility predicate is an immutable schema fragment; values are bound.
+ rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ query,
+ SUCCEEDED,
+ require_embedding,
+ require_structure,
+ limit + 1,
+ )
+ has_more = len(rows) > limit
+ for row in rows[:limit]:
+ post_id = str(row["post_id"])
+ complete = await post_content_is_complete(
+ conn,
+ post_id,
+ require_embedding=require_embedding,
+ require_structure=require_structure,
+ )
+ request = await ensure_post_content_job(
+ conn,
+ post_id,
+ str(row["post_body"] or ""),
+ content_complete=complete,
+ )
+ if request.should_publish:
+ requests.append(request)
+
+ published = 0
+ for request in requests:
+ if await publish_post_content_event(
+ client,
+ post_id=request.post_id,
+ source_body_digest=request.source_body_sha256,
+ ):
+ published += 1
+ return {
+ "selected_posts": min(len(rows), limit),
+ "queued_posts": len(requests),
+ "published_events": published,
+ "recovery_pending": len(requests) - published,
+ "has_more": has_more,
+ }
+
+
async def requeue_failed_post_content_job(
conn: asyncpg.Connection,
post_id: str,
diff --git a/docs/adr/0098-valkey-backed-post-content-ingestion.md b/docs/adr/0098-valkey-backed-post-content-ingestion.md
index 4077e398d..b106cb287 100644
--- a/docs/adr/0098-valkey-backed-post-content-ingestion.md
+++ b/docs/adr/0098-valkey-backed-post-content-ingestion.md
@@ -92,10 +92,18 @@ event.
## Corpus backfill (2026-08-20)
-Operational backfill MUST use `scripts/queue_post_content_backfill.py`. It
-selects only non-draft, non-deleted rows with real source context, records the
-same completeness-aware job state in PostgreSQL, and publishes wake-ups through
-Valkey. Direct provider calls are not a substitute for the worker queue.
+Operational backfill MUST use `scripts/queue_post_content_backfill.py` or
+`POST /api/post-content/backfill`; both call the same producer. The HTTP
+entry point requires `post_admin`, accepts only a 1--200 row page, and returns
+HTTP 202 after committing the ledger and attempting wake-ups; it never runs a
+provider in the request. The CLI has the same bound and no whole-corpus mode.
+The producer applies `SOURCE_POST_ELIGIBILITY_SQL`, locks source rows with
+`SKIP LOCKED`, selects only new or incomplete-succeeded jobs, rechecks the
+shared completeness predicate, and records the existing job state in
+PostgreSQL. Repeated calls therefore do not reset active or terminal work.
+If Valkey is unavailable, the response reports `recovery_pending` and the
+committed queued rows are republished by the existing recovery sweep. Direct
+provider calls are not a substitute for the worker queue.
### Operational timeout for structure adjudication
diff --git a/docs/product-requirements.md b/docs/product-requirements.md
index dbce16860..fcb9f741e 100644
--- a/docs/product-requirements.md
+++ b/docs/product-requirements.md
@@ -72,6 +72,9 @@ dangling endpoints fail closed; fixed input produces stable page boundaries.
- Preserve source representation and derive ordered paragraph, list, table,
formula, conversation-turn, and image-region semantic units.
- Route embeddings, LLM, and VISION through contextual-orchestrator.
+- Let an authorized administrator enqueue only a bounded page of eligible,
+ incomplete posts into the durable worker ledger; acknowledge before model
+ work and recover a missing broker wake-up from PostgreSQL.
- Apply authorization/time/process scope before ranking and again before
response delivery.
- Keep internal post citations separate from external public citations.
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 24ff4c85b..b83e0b9b7 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -439,7 +439,7 @@ this file per §3.5 of the prior snapshot).
| Gap | Current evidence | Acceptance requirement |
| --- | --- | --- |
| Protected release | 11 open PRs at the 07:26 KST snapshot; the exact-head inventory in section 1 records their current evidence boundaries | Terminal exact-head checks, no unresolved threads, independent exact-head approvals, protected squash-merge SHA |
-| Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. Authorized-corpus backfill acceptance remains unavailable | Perform authenticated authorized-corpus acceptance with aggregate evidence and retain fail-closed no-match behavior |
+| Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. The current Dashboard stack adds a candidate `post_admin`-gated, 1--200-row durable semantic-backfill enqueue path that reuses PostgreSQL recovery and never runs providers in HTTP; authorized-corpus acceptance remains unavailable | Land the candidate, then perform authenticated authorized-corpus acceptance with aggregate queued/published/recovery and derived-evidence counts while retaining fail-closed no-match behavior |
| Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push |
| Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc |
| Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence |
diff --git a/scripts/queue_post_content_backfill.py b/scripts/queue_post_content_backfill.py
index bac966ddc..c94d3ca97 100644
--- a/scripts/queue_post_content_backfill.py
+++ b/scripts/queue_post_content_backfill.py
@@ -17,9 +17,7 @@
sys.path.insert(0, str(REPOSITORY_ROOT))
from backend.app.post_content_queue import ( # noqa: E402
- ensure_post_content_job,
- post_content_is_complete,
- publish_post_content_event,
+ enqueue_post_content_backfill,
)
from backend.app.config import load_settings # noqa: E402
@@ -37,8 +35,7 @@ def _parser() -> argparse.ArgumentParser:
"--valkey-url",
default=os.environ.get("VALKEY_URL", "redis://localhost:16379/0"),
)
- parser.add_argument("--limit", type=int, default=100)
- parser.add_argument("--all", action="store_true", help="scan the complete real corpus")
+ parser.add_argument("--limit", type=int, choices=range(1, 201), default=100)
return parser
@@ -46,118 +43,28 @@ async def queue_post_content_backfill(
target_dsn: str,
valkey_url: str,
*,
- limit: int | None,
-) -> dict[str, int]:
- if limit is not None and limit < 1:
- raise ValueError("limit must be positive")
+ limit: int,
+) -> dict[str, int | bool]:
+ """Queue one bounded page through the shared durable producer."""
+ if not 1 <= limit <= 200:
+ raise ValueError("limit must be between 1 and 200")
settings = load_settings()
require_orchestrator_evidence = bool(
settings.orchestrator_base_url and settings.orchestrator_api_key
)
- connection = await asyncpg.connect(target_dsn)
+ pool = await asyncpg.create_pool(target_dsn, min_size=1, max_size=1)
client = redis.from_url(valkey_url, decode_responses=True)
- result = {"scanned_posts": 0, "already_complete": 0, "queued_posts": 0, "published_events": 0}
try:
- rows = await connection.fetch(
- """
- select post_id, post_body
- from source_post post
- where nullif(btrim(source_draft_code), '') is null
- and nullif(btrim(source_deleted_flag), '') is null
- and (
- nullif(btrim(source_author_code), '') is not null
- or nullif(btrim(source_author_name), '') is not null
- or nullif(btrim(source_company_code), '') is not null
- or nullif(btrim(source_company_name), '') is not null
- or nullif(btrim(source_process_unit_code), '') is not null
- or nullif(btrim(source_process_unit_name), '') is not null
- or nullif(btrim(source_sales_pool_code), '') is not null
- or nullif(btrim(source_sales_pool_name), '') is not null
- or nullif(btrim(source_customer_code), '') is not null
- or nullif(btrim(source_customer_name), '') is not null
- or nullif(btrim(source_project_code), '') is not null
- or nullif(btrim(source_project_name), '') is not null
- )
- and (
- not exists (
- select 1
- from post_content_unit unit
- where unit.post_id = post.post_id
- )
- or ($1::boolean and exists (
- select 1
- from post_content_unit unit
- left join post_content_embedding embedding
- on embedding.post_content_unit_id = unit.post_content_unit_id
- where unit.post_id = post.post_id
- and embedding.post_content_embedding_id is null
- ))
- or ($1::boolean and exists (
- select 1
- from post_content_unit unit
- join post_content_image image
- on image.post_content_unit_id = unit.post_content_unit_id
- join post_content_image_region region
- on region.post_content_image_id = image.post_content_image_id
- left join post_content_image_region_embedding embedding
- on embedding.post_content_image_region_id = region.post_content_image_region_id
- where unit.post_id = post.post_id
- and region.description_status_code = 'described'
- and embedding.post_content_image_region_embedding_id is null
- ))
- or ($2::boolean and exists (
- select 1
- from post_content_unit unit
- left join post_content_unit_structure structure
- on structure.post_content_unit_id = unit.post_content_unit_id
- where unit.post_id = post.post_id
- and unit.unit_kind_code <> 'image'
- and (
- structure.post_content_unit_structure_id is null
- or structure.decision_source_code = 'unresolved'
- )
- ))
- )
- order by post.created_at, post.post_id
- limit $3::bigint
- """,
- require_orchestrator_evidence,
- require_orchestrator_evidence,
- limit if limit is not None else 9223372036854775807,
+ return await enqueue_post_content_backfill(
+ pool,
+ client,
+ limit=limit,
+ require_embedding=require_orchestrator_evidence,
+ require_structure=require_orchestrator_evidence,
)
- for row in rows:
- result["scanned_posts"] += 1
- post_id = str(row["post_id"])
- async with connection.transaction():
- complete = await post_content_is_complete(
- connection,
- post_id,
- require_embedding=require_orchestrator_evidence,
- require_structure=require_orchestrator_evidence,
- )
- request = await ensure_post_content_job(
- connection,
- post_id,
- str(row["post_body"] or ""),
- content_complete=complete,
- )
- if complete and not request.should_publish:
- result["already_complete"] += 1
- continue
- if request.should_publish:
- entry_id = await publish_post_content_event(
- client,
- post_id=post_id,
- source_body_digest=request.source_body_sha256,
- )
- if entry_id is None:
- raise RuntimeError(f"Valkey did not publish post-content job {post_id}")
- result["published_events"] += 1
- result["queued_posts"] += 1
- return result
finally:
- await connection.close()
+ await pool.close()
await client.aclose()
@@ -167,7 +74,7 @@ def main() -> None:
queue_post_content_backfill(
args.target_dsn,
args.valkey_url,
- limit=None if args.all else args.limit,
+ limit=args.limit,
)
)
print(result)
diff --git a/tests/test_post_content_backfill_endpoint.py b/tests/test_post_content_backfill_endpoint.py
new file mode 100644
index 000000000..0be04bd8d
--- /dev/null
+++ b/tests/test_post_content_backfill_endpoint.py
@@ -0,0 +1,130 @@
+"""Authorization and request bounds for the semantic backfill operator API."""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app import main
+from backend.app.auth import CurrentAccount
+
+
+def _account(*permissions: str) -> CurrentAccount:
+ """Build one synthetic account without an OIDC or database dependency."""
+ return CurrentAccount(
+ user_account_id="00000000-0000-0000-0000-000000000001",
+ external_subject_id="synthetic-subject",
+ display_name="Synthetic operator",
+ preferred_locale="en",
+ corporate_entity_ids=frozenset(),
+ process_unit_ids=frozenset(),
+ permission_codes=frozenset(permissions),
+ )
+
+
+def test_backfill_endpoint_requires_post_admin() -> None:
+ """A reader cannot enqueue corpus-wide semantic processing."""
+ with pytest.raises(main.HTTPException) as raised:
+ asyncio.run(
+ main.queue_post_content_backfill(
+ main.PostContentBackfillRequest(),
+ account=_account("post_read"),
+ pool=object(),
+ valkey=object(),
+ )
+ )
+ assert raised.value.status_code == 403
+
+
+def test_backfill_request_limit_is_bounded() -> None:
+ """Pydantic rejects zero and corpus-sized operator requests."""
+ for limit in (0, 201):
+ with pytest.raises(ValidationError):
+ main.PostContentBackfillRequest(limit=limit)
+
+
+def test_backfill_endpoint_only_enqueues_durable_work(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The accepted response delegates once and never invokes a provider."""
+ observed: dict[str, object] = {}
+
+ async def enqueue(pool: object, valkey: object, **kwargs: object) -> dict[str, int | bool]:
+ observed.update(pool=pool, valkey=valkey, **kwargs)
+ return {
+ "selected_posts": 1,
+ "queued_posts": 1,
+ "published_events": 1,
+ "recovery_pending": 0,
+ "has_more": False,
+ }
+
+ monkeypatch.setattr(main, "enqueue_post_content_backfill", enqueue)
+ monkeypatch.setattr(
+ main,
+ "load_settings",
+ lambda: type(
+ "Settings",
+ (),
+ {"orchestrator_base_url": "https://orchestrator.invalid", "orchestrator_api_key": "configured"},
+ )(),
+ )
+ pool = object()
+ valkey = object()
+ result = asyncio.run(
+ main.queue_post_content_backfill(
+ main.PostContentBackfillRequest(limit=17),
+ account=_account("post_admin"),
+ pool=pool,
+ valkey=valkey,
+ )
+ )
+ assert result["queued_posts"] == 1
+ assert observed == {
+ "pool": pool,
+ "valkey": valkey,
+ "limit": 17,
+ "require_embedding": True,
+ "require_structure": True,
+ }
+
+
+def test_backfill_endpoint_does_not_require_missing_model_evidence(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """An unwired orchestrator remains unavailable instead of being fabricated."""
+ observed: dict[str, object] = {}
+
+ async def enqueue(_pool: object, _valkey: object, **kwargs: object) -> dict[str, int | bool]:
+ observed.update(kwargs)
+ return {
+ "selected_posts": 0,
+ "queued_posts": 0,
+ "published_events": 0,
+ "recovery_pending": 0,
+ "has_more": False,
+ }
+
+ monkeypatch.setattr(main, "enqueue_post_content_backfill", enqueue)
+ monkeypatch.setattr(
+ main,
+ "load_settings",
+ lambda: type(
+ "Settings", (), {"orchestrator_base_url": "", "orchestrator_api_key": ""}
+ )(),
+ )
+ asyncio.run(
+ main.queue_post_content_backfill(
+ main.PostContentBackfillRequest(),
+ account=_account("post_admin"),
+ pool=object(),
+ valkey=object(),
+ )
+ )
+ assert observed == {
+ "limit": 100,
+ "require_embedding": False,
+ "require_structure": False,
+ }
diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py
index b4ad853bb..0377067ce 100644
--- a/tests/test_post_content_queue.py
+++ b/tests/test_post_content_queue.py
@@ -17,6 +17,8 @@
QUEUED,
RUNNING,
SUCCEEDED,
+ PostContentJobRequest,
+ enqueue_post_content_backfill,
record_post_content_backfill_success,
requeue_failed_post_content_job,
post_content_api_status,
@@ -40,6 +42,151 @@ def test_stream_is_a_wakeup_and_never_contains_a_body() -> None:
assert source_body_sha256("body") != source_body_sha256("changed")
+def test_bounded_backfill_is_idempotent_and_broker_loss_stays_recoverable(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Select only new/succeeded work and retain queued rows after wake-up loss."""
+
+ class Transaction:
+ async def __aenter__(self) -> None:
+ return None
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ class Connection:
+ def transaction(self) -> Transaction:
+ return Transaction()
+
+ async def fetch(self, query: str, *args: object) -> list[dict[str, str]]:
+ assert "source_draft_code" in query
+ assert "source_deleted_flag" in query
+ assert "job.post_id is null or job.status_code = $1" in query
+ assert "for update of post skip locked" in query.lower()
+ assert args == (SUCCEEDED, True, True, 3)
+ return [
+ {"post_id": "00000000-0000-0000-0000-000000000001", "post_body": "one"},
+ {"post_id": "00000000-0000-0000-0000-000000000002", "post_body": "two"},
+ {"post_id": "00000000-0000-0000-0000-000000000003", "post_body": "three"},
+ ]
+
+ class Acquire:
+ async def __aenter__(self) -> Connection:
+ return Connection()
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ class Pool:
+ def acquire(self) -> Acquire:
+ return Acquire()
+
+ async def incomplete(*_args: object, **_kwargs: object) -> bool:
+ return False
+
+ async def ensure(
+ _conn: object, post_id: str, body: str, *, content_complete: bool
+ ) -> PostContentJobRequest:
+ assert content_complete is False
+ return PostContentJobRequest(post_id, source_body_sha256(body), QUEUED, True)
+
+ publish_calls = 0
+
+ async def publish(*_args: object, **_kwargs: object) -> str | None:
+ nonlocal publish_calls
+ publish_calls += 1
+ return "1-0" if publish_calls == 1 else None
+
+ from backend.app import post_content_queue
+
+ monkeypatch.setattr(post_content_queue, "post_content_is_complete", incomplete)
+ monkeypatch.setattr(post_content_queue, "ensure_post_content_job", ensure)
+ monkeypatch.setattr(post_content_queue, "publish_post_content_event", publish)
+
+ result = asyncio.run(
+ enqueue_post_content_backfill(
+ Pool(), object(), limit=2, require_embedding=True, require_structure=True
+ )
+ )
+ assert result == {
+ "selected_posts": 2,
+ "queued_posts": 2,
+ "published_events": 1,
+ "recovery_pending": 1,
+ "has_more": True,
+ }
+
+
+def test_backfill_skips_a_candidate_that_became_complete(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The shared completeness recheck wins over a stale candidate query."""
+
+ class Transaction:
+ async def __aenter__(self) -> None:
+ return None
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ class Connection:
+ def transaction(self) -> Transaction:
+ return Transaction()
+
+ async def fetch(self, _query: str, *_args: object) -> list[dict[str, str]]:
+ return [
+ {"post_id": "00000000-0000-0000-0000-000000000001", "post_body": "done"}
+ ]
+
+ class Acquire:
+ async def __aenter__(self) -> Connection:
+ return Connection()
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ class Pool:
+ def acquire(self) -> Acquire:
+ return Acquire()
+
+ async def complete(*_args: object, **_kwargs: object) -> bool:
+ return True
+
+ async def ensure(
+ _conn: object, post_id: str, body: str, *, content_complete: bool
+ ) -> PostContentJobRequest:
+ assert content_complete is True
+ return PostContentJobRequest(post_id, source_body_sha256(body), SUCCEEDED, False)
+
+ from backend.app import post_content_queue
+
+ monkeypatch.setattr(post_content_queue, "post_content_is_complete", complete)
+ monkeypatch.setattr(post_content_queue, "ensure_post_content_job", ensure)
+ result = asyncio.run(
+ enqueue_post_content_backfill(
+ Pool(), object(), limit=2, require_embedding=False, require_structure=False
+ )
+ )
+ assert result == {
+ "selected_posts": 1,
+ "queued_posts": 0,
+ "published_events": 0,
+ "recovery_pending": 0,
+ "has_more": False,
+ }
+
+
+@pytest.mark.parametrize("limit", [0, 201])
+def test_backfill_rejects_unbounded_pages(limit: int) -> None:
+ """The shared producer rejects callers that bypass the HTTP model bound."""
+ with pytest.raises(ValueError, match="between 1 and 200"):
+ asyncio.run(
+ enqueue_post_content_backfill(
+ object(), object(), limit=limit, require_embedding=False, require_structure=False
+ )
+ )
+
+
def test_api_status_does_not_call_failed_content_ready() -> None:
assert post_content_api_status(QUEUED, content_present=False) == "processing"
assert post_content_api_status(QUEUED, content_present=True) == "processing"
diff --git a/tests/test_queue_post_content_backfill_script.py b/tests/test_queue_post_content_backfill_script.py
new file mode 100644
index 000000000..d7835991c
--- /dev/null
+++ b/tests/test_queue_post_content_backfill_script.py
@@ -0,0 +1,96 @@
+"""The operator CLI reuses the bounded durable producer."""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+
+from scripts import queue_post_content_backfill as script
+
+
+def test_parser_and_main_keep_the_operator_page_bounded(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+ """The executable accepts one bounded page and prints aggregate evidence."""
+ parser = script._parser()
+ assert parser.parse_args(["--limit", "200"]).limit == 200
+ with pytest.raises(SystemExit):
+ parser.parse_args(["--limit", "201"])
+
+ async def queue(*_args: object, **kwargs: object) -> dict[str, int]:
+ assert kwargs == {"limit": 7}
+ return {"queued_posts": 2}
+
+ monkeypatch.setattr(
+ script,
+ "_parser",
+ lambda: SimpleNamespace(
+ parse_args=lambda: SimpleNamespace(
+ target_dsn="postgresql://invalid",
+ valkey_url="redis://invalid",
+ limit=7,
+ )
+ ),
+ )
+ monkeypatch.setattr(script, "queue_post_content_backfill", queue)
+ script.main()
+ assert capsys.readouterr().out.strip() == "{'queued_posts': 2}"
+
+
+def test_script_uses_one_connection_pool_and_closes_resources(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The CLI delegates once and closes both transport handles."""
+ closed: list[str] = []
+
+ class Pool:
+ async def close(self) -> None:
+ closed.append("pool")
+
+ class Client:
+ async def aclose(self) -> None:
+ closed.append("client")
+
+ pool = Pool()
+ client = Client()
+
+ async def create_pool(*_args: object, **_kwargs: object) -> Pool:
+ return pool
+
+ async def enqueue(_pool: object, _client: object, **kwargs: object) -> dict[str, int | bool]:
+ assert (_pool, _client) == (pool, client)
+ assert kwargs == {
+ "limit": 12,
+ "require_embedding": True,
+ "require_structure": True,
+ }
+ return {"queued_posts": 1}
+
+ monkeypatch.setattr(script.asyncpg, "create_pool", create_pool)
+ monkeypatch.setattr(script.redis, "from_url", lambda *_args, **_kwargs: client)
+ monkeypatch.setattr(script, "enqueue_post_content_backfill", enqueue)
+ monkeypatch.setattr(
+ script,
+ "load_settings",
+ lambda: type(
+ "Settings", (), {"orchestrator_base_url": "https://example.invalid", "orchestrator_api_key": "set"}
+ )(),
+ )
+ result = asyncio.run(
+ script.queue_post_content_backfill("postgresql://invalid", "redis://invalid", limit=12)
+ )
+ assert result == {"queued_posts": 1}
+ assert closed == ["pool", "client"]
+
+
+@pytest.mark.parametrize("limit", [0, 201])
+def test_script_rejects_unbounded_limits_before_connecting(limit: int) -> None:
+ """Invalid pages fail before any database or broker connection."""
+ with pytest.raises(ValueError, match="between 1 and 200"):
+ asyncio.run(
+ script.queue_post_content_backfill(
+ "postgresql://invalid", "redis://invalid", limit=limit
+ )
+ )
diff --git a/tests/test_static_sql_review_contracts.py b/tests/test_static_sql_review_contracts.py
index 31c7896bc..d09df90d8 100644
--- a/tests/test_static_sql_review_contracts.py
+++ b/tests/test_static_sql_review_contracts.py
@@ -19,6 +19,7 @@
"backend/app/entity_relationship_ingestion.py",
"backend/app/knowledge_graph.py",
"backend/app/main.py",
+ "backend/app/post_content_queue.py",
"backend/app/report_ingestion.py",
"lineageweave/synthetic_seed_cleanup.py",
"scripts/backfill_post_content.py",
@@ -28,7 +29,7 @@
)
ASYNC_STATEMENT_METHODS = {"execute", "fetch", "fetchrow", "fetchval"}
SQL_REVIEW_RULE = "python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli"
-EXPECTED_SQL_SUPPRESSION_COUNT = 36
+EXPECTED_SQL_SUPPRESSION_COUNT = 37
@pytest.mark.parametrize("relative_path", SQL_REVIEW_PATHS)
From 49f0369e0e095c562cfd8d874f60589ce7c08355 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 19:10:33 -0700
Subject: [PATCH 082/393] ops(postgres): plan measured runtime tuning (#684)
* ops(postgres): plan measured runtime tuning
* fix(postgres): support measured native setting units
---------
Co-authored-by: Codex
---
CHANGELOG.md | 6 +
docker-compose.postgres-tuned.yml | 14 +
...0227-observed-postgresql-runtime-tuning.md | 91 ++++
docs/adr/README.md | 1 +
.../operability/postgresql-observed-tuning.md | 48 ++
docs/product-technical-gap-baseline.md | 1 +
scripts/plan_postgres_tuning.py | 458 +++++++++++++++++
tests/test_postgres_tuning_plan.py | 475 ++++++++++++++++++
8 files changed, 1094 insertions(+)
create mode 100644 docker-compose.postgres-tuned.yml
create mode 100644 docs/adr/0227-observed-postgresql-runtime-tuning.md
create mode 100644 docs/operability/postgresql-observed-tuning.md
create mode 100644 scripts/plan_postgres_tuning.py
create mode 100644 tests/test_postgres_tuning_plan.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 561ea7b9c..9efed2cde 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,12 @@ All notable changes to this project are documented here. Format follows
### Added
+- PostgreSQL Compose now has a plan-first automatic tuning procedure that
+ samples aligned WAL/checkpoint counters and container resources, calculates
+ only segment- and checkpoint-bound settings, validates an auditable plan,
+ preserves durability, and applies or rolls back solely through an approved
+ controlled restart.
+
- Global Ask answers now link numbered citations to authorized event cards in
both directions. Each card names event time or the record-time fallback and
opens the focused evidence layer or full source post without inventing a
diff --git a/docker-compose.postgres-tuned.yml b/docker-compose.postgres-tuned.yml
new file mode 100644
index 000000000..ef3843e1d
--- /dev/null
+++ b/docker-compose.postgres-tuned.yml
@@ -0,0 +1,14 @@
+services:
+ postgres:
+ command:
+ - postgres
+ - -c
+ - max_wal_size=${POSTGRES_TUNED_MAX_WAL_SIZE:?generate and validate a tuning plan first}
+ - -c
+ - wal_buffers=${POSTGRES_TUNED_WAL_BUFFERS:?generate and validate a tuning plan first}
+ - -c
+ - fsync=${POSTGRES_TUNED_FSYNC:?generate and validate a tuning plan first}
+ - -c
+ - full_page_writes=${POSTGRES_TUNED_FULL_PAGE_WRITES:?generate and validate a tuning plan first}
+ - -c
+ - synchronous_commit=${POSTGRES_TUNED_SYNCHRONOUS_COMMIT:?generate and validate a tuning plan first}
diff --git a/docs/adr/0227-observed-postgresql-runtime-tuning.md b/docs/adr/0227-observed-postgresql-runtime-tuning.md
new file mode 100644
index 000000000..20ecf7819
--- /dev/null
+++ b/docs/adr/0227-observed-postgresql-runtime-tuning.md
@@ -0,0 +1,91 @@
+# ADR 0227: Observed PostgreSQL runtime tuning
+
+- Status: Accepted
+- Date: 2026-08-26
+
+## Context
+
+The canonical PostgreSQL 16 runtime has accumulated substantially more
+requested than timed checkpoints and millions of `wal_buffers_full` events.
+The currently running full-text index scan is CPU-bound and produces negligible
+new WAL, so it is not evidence for changing storage concurrency or maintenance
+memory. Historical cumulative counters are also unsafe to combine when their
+statistics-reset instants differ.
+
+Static host-size profiles and conventional memory percentages would introduce
+unsupported assumptions. PostgreSQL already supplies an automatic
+`wal_buffers` calculation, a WAL-segment boundary, a configured checkpoint
+interval, and cumulative workload counters. Those are the authoritative inputs
+for the smallest measured correction.
+
+## Decision
+
+`scripts/plan_postgres_tuning.py` is the sole LineageWeave procedure for this
+runtime tuning boundary. It performs two measurements separated by an
+operator-declared observation duration and records:
+
+- PostgreSQL version and statistics-reset instants;
+- `pg_stat_wal` and checkpoint deltas;
+- current durability and tuning settings;
+- `wal_segment_size` and the existing `checkpoint_timeout`;
+- container memory limit, data-filesystem free bytes, and current `pg_wal`
+ bytes.
+
+The planner rejects counter resets, negative deltas, unsupported PostgreSQL
+versions, incomplete durability evidence, or insufficient disk space. It emits
+an immutable JSON audit plan and a Compose environment file. It never applies a
+setting while PostgreSQL is running.
+
+The planner separately calculates WAL rates for the explicit sample and for
+PostgreSQL's own `stats_reset` to snapshot window. The calculated
+`max_wal_size` is the larger of its current value and the higher observed rate
+projected over one already-configured checkpoint interval, rounded upward to
+PostgreSQL's own WAL-segment size. This preserves historical write pressure
+when the immediate sample is a CPU-bound, zero-WAL scan and directly targets
+the documented condition in which WAL growth starts a checkpoint before
+`checkpoint_timeout`; it does not add a private safety multiplier. When neither
+window supports a larger value, `max_wal_size` remains unchanged even if the
+requested-checkpoint count is high, because that counter does not prove which
+request source caused each checkpoint.
+
+If either aligned observation window records at least one `wal_buffers_full` event,
+`wal_buffers` becomes one measured WAL segment. PostgreSQL 16 documents one WAL
+segment as the normal upper bound of its automatic selection. With no observed
+full event, the current value remains unchanged.
+
+The procedure does **not** infer `shared_buffers`, `maintenance_work_mem`,
+`effective_io_concurrency`, `maintenance_io_concurrency`, or
+`wal_compression`. Their documented trade-offs require workload-specific memory
+or storage latency/IOPS evidence that the WAL/checkpoint observation does not
+provide. A CPU-bound index scan is explicitly not storage-concurrency evidence.
+
+`fsync`, `full_page_writes`, and `synchronous_commit` must all remain enabled.
+The generated environment file is consumed only by the explicit
+`docker-compose.postgres-tuned.yml` overlay during a controlled PostgreSQL
+restart. The base Compose file remains the rollback path: remove the overlay
+and restart PostgreSQL. The JSON plan records both proposed and rollback
+values.
+
+## Consequences
+
+- A tuning proposal is reproducible from captured measurements and contains no
+ hand-selected weights, ratios, or thresholds.
+- A short or unrepresentative observation can retain current settings but
+ cannot silently tune them.
+- Increased `max_wal_size` can lengthen crash recovery and consume more disk;
+ the plan exposes both effects and refuses a proposal whose exact additional
+ reservation exceeds observed free space.
+- Applying or rolling back requires an intentional service restart and normal
+ post-restart health/config verification.
+
+## References
+
+PostgreSQL Global Development Group. (2026a). *PostgreSQL 16 documentation:
+20.5. Write ahead log*. https://www.postgresql.org/docs/16/runtime-config-wal.html
+
+PostgreSQL Global Development Group. (2026b). *PostgreSQL 16 documentation:
+30.5. WAL configuration*. https://www.postgresql.org/docs/16/wal-configuration.html
+
+PostgreSQL Global Development Group. (2026c). *PostgreSQL 16 documentation:
+20.4. Resource consumption*.
+https://www.postgresql.org/docs/16/runtime-config-resource.html
diff --git a/docs/adr/README.md b/docs/adr/README.md
index b12fc90b4..2245a7cd8 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -26,6 +26,7 @@ decision from them.
| Canonical Docker Compose project (`name: lineageweave`) | [0224](0224-canonical-compose-project.md) |
| Ask answer citation and evidence-timeline interaction | [0225](0225-ask-answer-evidence-timeline.md) |
| macOS-native Rust/MLX mathematical compute boundary | [0226](0226-macos-native-mlx-mathematical-compute-boundary.md), [0208](0208-externalize-local-mathematical-compute.md) |
+| Observed PostgreSQL WAL/checkpoint tuning plan | [0227](0227-observed-postgresql-runtime-tuning.md) |
[0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/).
diff --git a/docs/operability/postgresql-observed-tuning.md b/docs/operability/postgresql-observed-tuning.md
new file mode 100644
index 000000000..5433548d4
--- /dev/null
+++ b/docs/operability/postgresql-observed-tuning.md
@@ -0,0 +1,48 @@
+# PostgreSQL observed tuning procedure
+
+This procedure produces a plan before it changes a service. Run it only after
+the canonical migration and other controlled database work have completed.
+The observation duration is required rather than defaulted: select a window
+that contains the workload being tuned and record that choice with the plan.
+
+```bash
+uv run python scripts/plan_postgres_tuning.py plan \
+ --sample-seconds "$OBSERVATION_SECONDS" \
+ --output /tmp/lineageweave-postgres-tuning-plan.json
+
+uv run python scripts/plan_postgres_tuning.py validate \
+ --plan /tmp/lineageweave-postgres-tuning-plan.json \
+ --env-output /tmp/lineageweave-postgres-tuning.env
+```
+
+Review the JSON evidence, proposed settings, exact disk reservation, retained
+settings, and rollback values. Validation renders the Compose configuration but
+does not touch a container.
+
+Apply only in an approved restart window. Copy the printed `plan_id` exactly;
+the procedure rejects a changed plan or a different approval value.
+
+```bash
+uv run python scripts/plan_postgres_tuning.py apply \
+ --plan /tmp/lineageweave-postgres-tuning-plan.json \
+ --env-output /tmp/lineageweave-postgres-tuning.env \
+ --approve-plan-id "$APPROVED_PLAN_ID"
+```
+
+After PostgreSQL becomes healthy, compare `SHOW max_wal_size`,
+`SHOW wal_buffers`, all three durability settings, `pg_stat_wal`, and
+checkpoint counters with the plan. Do not attribute the CPU time of an active
+GIN scan to WAL or storage concurrency when its sampled WAL delta is zero.
+
+Rollback uses the plan's captured pre-change values and the same controlled
+restart gate:
+
+```bash
+uv run python scripts/plan_postgres_tuning.py rollback \
+ --plan /tmp/lineageweave-postgres-tuning-plan.json \
+ --env-output /tmp/lineageweave-postgres-rollback.env \
+ --approve-plan-id "$APPROVED_PLAN_ID"
+```
+
+The base `docker-compose.yml` contains no tuned command. Removing the tuning
+overlay and recreating PostgreSQL is the secondary rollback path.
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 24ff4c85b..c7ddd5308 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -32,6 +32,7 @@ containers while preserving named volumes.
| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending |
| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet |
| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open at `063f10f3`; stacked #251–#254 provide fail-closed input validation, full joint precision, deterministic joint plausible-value draws, and the canonical research register, while complete provenance assembly remains gated. fast-mlsirm PR #1418 validates the Rust consumer envelope but intentionally returns `EstimatorUnavailable` until the scientific estimator lands. Runtime therefore remains honestly unavailable with no local Python or fallback score. |
+| PostgreSQL WAL/checkpoint pressure | ADR 0227; aligned two-snapshot `pg_stat_wal`/checkpoint deltas, PostgreSQL WAL-segment and checkpoint constraints, cgroup memory, and data-volume space | Candidate procedure emits a content-authenticated plan and Compose environment, retains unmeasured memory/I/O/compression settings, preserves durability, validates the overlay without mutation, rejects stale preconditions, and requires an approved service recreation for apply or rollback. The observed CPU-bound GIN scan remains distinct from historical checkpoint pressure; canonical runtime application waits for the active migration to complete. |
### Technical contract and flow
diff --git a/scripts/plan_postgres_tuning.py b/scripts/plan_postgres_tuning.py
new file mode 100644
index 000000000..61808189f
--- /dev/null
+++ b/scripts/plan_postgres_tuning.py
@@ -0,0 +1,458 @@
+#!/usr/bin/env python3
+"""Measure, plan, validate, and deliberately apply PostgreSQL Compose tuning."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import math
+import subprocess
+import time
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Callable, Mapping, Sequence
+
+MIB = 1024 * 1024
+KIB = 1024
+SUPPORTED_SERVER_MAJOR = 16
+DURABILITY_SETTINGS = ("fsync", "full_page_writes", "synchronous_commit")
+TUNED_COMPOSE_FILE = "docker-compose.postgres-tuned.yml"
+
+SNAPSHOT_SQL = r"""
+SELECT json_build_object(
+ 'captured_at', clock_timestamp(),
+ 'server_version_num', current_setting('server_version_num')::integer,
+ 'wal_stats_reset', w.stats_reset,
+ 'checkpoint_stats_reset', b.stats_reset,
+ 'wal_bytes', w.wal_bytes::text,
+ 'wal_buffers_full', w.wal_buffers_full,
+ 'checkpoints_timed', b.checkpoints_timed,
+ 'checkpoints_req', b.checkpoints_req,
+ 'wal_segment_size_bytes', pg_size_bytes(current_setting('wal_segment_size')),
+ 'settings', json_build_object(
+ 'checkpoint_timeout_seconds',
+ EXTRACT(EPOCH FROM current_setting('checkpoint_timeout')::interval),
+ 'max_wal_size_bytes', pg_size_bytes(current_setting('max_wal_size')),
+ 'min_wal_size_bytes', pg_size_bytes(current_setting('min_wal_size')),
+ 'wal_buffers_bytes', pg_size_bytes(current_setting('wal_buffers')),
+ 'shared_buffers_bytes', pg_size_bytes(current_setting('shared_buffers')),
+ 'maintenance_work_mem_bytes', pg_size_bytes(current_setting('maintenance_work_mem')),
+ 'effective_io_concurrency', current_setting('effective_io_concurrency')::integer,
+ 'maintenance_io_concurrency', current_setting('maintenance_io_concurrency')::integer,
+ 'wal_compression', current_setting('wal_compression'),
+ 'fsync', current_setting('fsync'),
+ 'full_page_writes', current_setting('full_page_writes'),
+ 'synchronous_commit', current_setting('synchronous_commit')
+ )
+)
+FROM pg_stat_wal AS w CROSS JOIN pg_stat_bgwriter AS b;
+"""
+
+
+class TuningPlanError(ValueError):
+ """Reject incomplete or unsafe tuning evidence."""
+
+
+@dataclass(frozen=True)
+class Observation:
+ """Two PostgreSQL counter snapshots and measured container resources."""
+
+ before: Mapping[str, Any]
+ after: Mapping[str, Any]
+ elapsed_seconds: float
+ container_memory_limit_bytes: int | None
+ data_filesystem_free_bytes: int
+ pg_wal_bytes: int
+
+
+def _integer(value: Any, field: str) -> int:
+ """Return one non-negative integer field or reject the snapshot."""
+ try:
+ result = int(value)
+ except (TypeError, ValueError) as exc:
+ raise TuningPlanError(f"{field} must be an integer") from exc
+ if result < 0:
+ raise TuningPlanError(f"{field} must not be negative")
+ return result
+
+
+def _delta(before: Mapping[str, Any], after: Mapping[str, Any], field: str) -> int:
+ """Calculate a monotonic PostgreSQL statistics-counter delta."""
+ result = _integer(after.get(field), field) - _integer(before.get(field), field)
+ if result < 0:
+ raise TuningPlanError(f"{field} decreased during the observation")
+ return result
+
+
+def _require_aligned_resets(before: Mapping[str, Any], after: Mapping[str, Any]) -> None:
+ """Reject observations spanning a PostgreSQL statistics reset."""
+ for field in ("wal_stats_reset", "checkpoint_stats_reset"):
+ if not before.get(field) or before.get(field) != after.get(field):
+ raise TuningPlanError(f"{field} changed or is unavailable")
+
+
+def _seconds_since(snapshot: Mapping[str, Any], reset_field: str) -> float:
+ """Calculate one PostgreSQL-owned cumulative statistics window."""
+ try:
+ captured = datetime.fromisoformat(str(snapshot["captured_at"]).replace("Z", "+00:00"))
+ reset = datetime.fromisoformat(str(snapshot[reset_field]).replace("Z", "+00:00"))
+ except (KeyError, ValueError) as exc:
+ raise TuningPlanError(f"{reset_field} observation window is invalid") from exc
+ seconds = (captured - reset).total_seconds()
+ if seconds <= 0:
+ raise TuningPlanError(f"{reset_field} observation window must be positive")
+ return seconds
+
+
+def _settings(snapshot: Mapping[str, Any]) -> Mapping[str, Any]:
+ """Return the measured PostgreSQL settings object."""
+ settings = snapshot.get("settings")
+ if not isinstance(settings, Mapping):
+ raise TuningPlanError("settings are unavailable")
+ return settings
+
+
+def _require_durability(settings: Mapping[str, Any]) -> None:
+ """Fail closed unless PostgreSQL durability remains enabled."""
+ accepted = {"on", "true", "remote_apply", "remote_write", "local"}
+ for field in DURABILITY_SETTINGS:
+ if str(settings.get(field, "")).lower() not in accepted:
+ raise TuningPlanError(f"durability setting {field} must remain enabled")
+
+
+def _durability_value(settings: Mapping[str, Any], field: str) -> str:
+ """Return one validated PostgreSQL durability setting unchanged."""
+ value = str(settings.get(field, "")).lower()
+ allowed = {
+ "fsync": {"on"},
+ "full_page_writes": {"on"},
+ "synchronous_commit": {"on", "remote_apply", "remote_write", "local"},
+ }
+ if value not in allowed[field]:
+ raise TuningPlanError(f"unsupported durability value for {field}")
+ return value
+
+
+def build_plan(observation: Observation) -> dict[str, Any]:
+ """Build an evidence-derived, restart-only PostgreSQL tuning plan."""
+ if observation.elapsed_seconds <= 0:
+ raise TuningPlanError("elapsed_seconds must be positive")
+ if observation.data_filesystem_free_bytes < 0 or observation.pg_wal_bytes < 0:
+ raise TuningPlanError("filesystem measurements must not be negative")
+ _require_aligned_resets(observation.before, observation.after)
+ server_major = (
+ _integer(observation.after.get("server_version_num"), "server_version_num")
+ // 10000
+ )
+ if server_major != SUPPORTED_SERVER_MAJOR:
+ raise TuningPlanError("the tuning contract supports PostgreSQL 16 only")
+
+ before_settings = _settings(observation.before)
+ after_settings = _settings(observation.after)
+ if before_settings != after_settings:
+ raise TuningPlanError("PostgreSQL settings changed during the observation")
+ _require_durability(after_settings)
+
+ segment_bytes = _integer(
+ observation.after.get("wal_segment_size_bytes"), "wal_segment_size_bytes"
+ )
+ if segment_bytes == 0 or segment_bytes % MIB:
+ raise TuningPlanError("wal_segment_size must be a positive whole number of MiB")
+ timeout_seconds = float(after_settings.get("checkpoint_timeout_seconds", 0))
+ if timeout_seconds <= 0:
+ raise TuningPlanError("checkpoint_timeout_seconds must be positive")
+
+ wal_bytes = _delta(observation.before, observation.after, "wal_bytes")
+ wal_buffers_full = _delta(observation.before, observation.after, "wal_buffers_full")
+ checkpoints_timed = _delta(observation.before, observation.after, "checkpoints_timed")
+ checkpoints_req = _delta(observation.before, observation.after, "checkpoints_req")
+ sample_wal_rate = wal_bytes / observation.elapsed_seconds
+ cumulative_wal_seconds = _seconds_since(observation.after, "wal_stats_reset")
+ cumulative_wal_rate = (
+ _integer(observation.after.get("wal_bytes"), "wal_bytes") / cumulative_wal_seconds
+ )
+ selected_wal_rate = max(sample_wal_rate, cumulative_wal_rate)
+ interval_wal_bytes = math.ceil(selected_wal_rate * timeout_seconds)
+ interval_wal_segments = (
+ math.ceil(interval_wal_bytes / segment_bytes) if interval_wal_bytes else 0
+ )
+
+ current_max_wal = _integer(after_settings.get("max_wal_size_bytes"), "max_wal_size_bytes")
+ current_wal_buffers = _integer(after_settings.get("wal_buffers_bytes"), "wal_buffers_bytes")
+ proposed_max_wal = max(current_max_wal, interval_wal_segments * segment_bytes)
+ cumulative_wal_buffers_full = _integer(
+ observation.after.get("wal_buffers_full"), "wal_buffers_full"
+ )
+ proposed_wal_buffers = (
+ segment_bytes
+ if wal_buffers_full or cumulative_wal_buffers_full
+ else current_wal_buffers
+ )
+
+ existing_wal_reservation = max(current_max_wal, observation.pg_wal_bytes)
+ additional_reservation = max(0, proposed_max_wal - existing_wal_reservation)
+ if additional_reservation > observation.data_filesystem_free_bytes:
+ raise TuningPlanError(
+ "measured filesystem free space cannot hold the additional WAL reservation"
+ )
+ if (
+ observation.container_memory_limit_bytes is not None
+ and proposed_wal_buffers > observation.container_memory_limit_bytes
+ ):
+ raise TuningPlanError("container memory limit cannot hold the proposed WAL buffers")
+
+ plan: dict[str, Any] = {
+ "contract_version": 1,
+ "requires_controlled_restart": True,
+ "evidence": {
+ "server_version_num": observation.after["server_version_num"],
+ "before_captured_at": observation.before.get("captured_at"),
+ "after_captured_at": observation.after.get("captured_at"),
+ "wal_stats_reset": observation.after["wal_stats_reset"],
+ "checkpoint_stats_reset": observation.after["checkpoint_stats_reset"],
+ "elapsed_seconds": observation.elapsed_seconds,
+ "wal_bytes": wal_bytes,
+ "sample_wal_bytes_per_second": sample_wal_rate,
+ "cumulative_wal_seconds": cumulative_wal_seconds,
+ "cumulative_wal_bytes_per_second": cumulative_wal_rate,
+ "selected_wal_bytes_per_second": selected_wal_rate,
+ "wal_buffers_full": wal_buffers_full,
+ "cumulative_wal_buffers_full": cumulative_wal_buffers_full,
+ "checkpoints_timed": checkpoints_timed,
+ "checkpoints_requested": checkpoints_req,
+ "checkpoint_timeout_seconds": timeout_seconds,
+ "wal_segment_size_bytes": segment_bytes,
+ "container_memory_limit_bytes": observation.container_memory_limit_bytes,
+ "data_filesystem_free_bytes": observation.data_filesystem_free_bytes,
+ "pg_wal_bytes": observation.pg_wal_bytes,
+ "additional_wal_reservation_bytes": additional_reservation,
+ },
+ "proposed": {
+ "max_wal_size_bytes": proposed_max_wal,
+ "wal_buffers_bytes": proposed_wal_buffers,
+ **{
+ field: _durability_value(after_settings, field)
+ for field in DURABILITY_SETTINGS
+ },
+ },
+ "rollback": {
+ "max_wal_size_bytes": current_max_wal,
+ "wal_buffers_bytes": current_wal_buffers,
+ "fsync": str(after_settings["fsync"]),
+ "full_page_writes": str(after_settings["full_page_writes"]),
+ "synchronous_commit": str(after_settings["synchronous_commit"]),
+ },
+ "retained_unmeasured": {
+ name: after_settings.get(name)
+ for name in (
+ "shared_buffers_bytes",
+ "maintenance_work_mem_bytes",
+ "effective_io_concurrency",
+ "maintenance_io_concurrency",
+ "wal_compression",
+ "min_wal_size_bytes",
+ )
+ },
+ }
+ canonical = json.dumps(plan, sort_keys=True, separators=(",", ":")).encode()
+ plan["plan_id"] = hashlib.sha256(canonical).hexdigest()
+ return plan
+
+
+def plan_environment(plan: Mapping[str, Any], *, rollback: bool = False) -> str:
+ """Render the proposed or rollback settings as a Compose environment file."""
+ section_name = "rollback" if rollback else "proposed"
+ section = plan.get(section_name)
+ if not isinstance(section, Mapping):
+ raise TuningPlanError(f"{section_name} settings are unavailable")
+ max_wal = _integer(section.get("max_wal_size_bytes"), "max_wal_size_bytes")
+ wal_buffers = _integer(section.get("wal_buffers_bytes"), "wal_buffers_bytes")
+ if max_wal % MIB:
+ raise TuningPlanError("max_wal_size must be a whole MiB value")
+ if wal_buffers % KIB:
+ raise TuningPlanError("wal_buffers must be a whole KiB value")
+ wal_buffers_setting = (
+ f"{wal_buffers // MIB}MB"
+ if wal_buffers % MIB == 0
+ else f"{wal_buffers // KIB}kB"
+ )
+ durability = {
+ field: _durability_value(section, field) for field in DURABILITY_SETTINGS
+ }
+ return (
+ f"POSTGRES_TUNED_MAX_WAL_SIZE={max_wal // MIB}MB\n"
+ f"POSTGRES_TUNED_WAL_BUFFERS={wal_buffers_setting}\n"
+ f"POSTGRES_TUNED_FSYNC={durability['fsync']}\n"
+ f"POSTGRES_TUNED_FULL_PAGE_WRITES={durability['full_page_writes']}\n"
+ f"POSTGRES_TUNED_SYNCHRONOUS_COMMIT={durability['synchronous_commit']}\n"
+ )
+
+
+def _run(command: Sequence[str], *, input_text: str | None = None) -> str:
+ """Run one bounded local command and return standard output."""
+ completed = subprocess.run(
+ list(command), input=input_text, text=True, capture_output=True, check=False
+ )
+ if completed.returncode:
+ raise TuningPlanError(completed.stderr.strip() or "command failed")
+ return completed.stdout.strip()
+
+
+def _postgres_snapshot() -> dict[str, Any]:
+ """Read one PostgreSQL statistics snapshot through canonical Compose."""
+ postgres_user = _run(
+ ["docker", "compose", "exec", "-T", "postgres", "printenv", "POSTGRES_USER"]
+ )
+ postgres_database = _run(
+ ["docker", "compose", "exec", "-T", "postgres", "printenv", "POSTGRES_DB"]
+ )
+ output = _run(
+ [
+ "docker", "compose", "exec", "-T", "postgres", "psql",
+ "-X", "-v", "ON_ERROR_STOP=1", "-At", "-U",
+ postgres_user, "-d", postgres_database, "-c", SNAPSHOT_SQL,
+ ]
+ )
+ value = json.loads(output)
+ if not isinstance(value, dict):
+ raise TuningPlanError("PostgreSQL snapshot is not a JSON object")
+ return value
+
+
+def _container_resources() -> tuple[int | None, int, int]:
+ """Measure cgroup memory and data-volume space from the PostgreSQL container."""
+ output = _run(
+ [
+ "docker", "compose", "exec", "-T", "postgres", "sh", "-eu", "-c",
+ "if [ -r /sys/fs/cgroup/memory.max ]; then cat /sys/fs/cgroup/memory.max; "
+ "elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then "
+ "cat /sys/fs/cgroup/memory/memory.limit_in_bytes; else printf 'max\\n'; fi; "
+ "df -Pk /var/lib/postgresql/data | awk 'NR==2 {print $4}'; "
+ "du -sk /var/lib/postgresql/data/pg_wal | awk '{print $1}'",
+ ]
+ ).splitlines()
+ if len(output) != 3:
+ raise TuningPlanError("container resource measurement is incomplete")
+ memory = None if output[0] == "max" else _integer(output[0], "container_memory_limit_bytes")
+ return (
+ memory,
+ _integer(output[1], "data_filesystem_free_kib") * 1024,
+ _integer(output[2], "pg_wal_kib") * 1024,
+ )
+
+
+def measure(sample_seconds: float, *, sleeper: Callable[[float], None] = time.sleep) -> Observation:
+ """Measure PostgreSQL deltas over an explicitly selected observation window."""
+ if sample_seconds <= 0:
+ raise TuningPlanError("sample_seconds must be positive")
+ before = _postgres_snapshot()
+ started = time.monotonic()
+ sleeper(sample_seconds)
+ after = _postgres_snapshot()
+ elapsed = time.monotonic() - started
+ memory, free_bytes, pg_wal_bytes = _container_resources()
+ return Observation(before, after, elapsed, memory, free_bytes, pg_wal_bytes)
+
+
+def _load_plan(path: Path) -> dict[str, Any]:
+ """Load and authenticate one generated audit plan."""
+ value = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(value, dict) or "plan_id" not in value:
+ raise TuningPlanError("plan is incomplete")
+ plan_id = value.pop("plan_id")
+ canonical = json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
+ expected = hashlib.sha256(canonical).hexdigest()
+ value["plan_id"] = plan_id
+ if plan_id != expected:
+ raise TuningPlanError("plan content does not match plan_id")
+ return value
+
+
+def validate_compose(plan: Mapping[str, Any], env_path: Path) -> None:
+ """Validate the explicit tuning overlay without changing a container."""
+ env_path.write_text(plan_environment(plan), encoding="utf-8")
+ _run(
+ [
+ "docker", "compose", "--env-file", str(env_path),
+ "-f", "docker-compose.yml", "-f", TUNED_COMPOSE_FILE, "config", "--quiet",
+ ]
+ )
+
+
+def controlled_restart(plan: Mapping[str, Any], env_path: Path, approval: str) -> None:
+ """Apply a validated plan only through an explicit PostgreSQL recreation."""
+ if approval != plan.get("plan_id"):
+ raise TuningPlanError("--approve-plan-id must match the audited plan")
+ current = _settings(_postgres_snapshot())
+ rollback = plan.get("rollback")
+ if not isinstance(rollback, Mapping):
+ raise TuningPlanError("rollback settings are unavailable")
+ for field in ("max_wal_size_bytes", "wal_buffers_bytes"):
+ if _integer(current.get(field), field) != _integer(rollback.get(field), field):
+ raise TuningPlanError(f"current {field} no longer matches the audited plan")
+ _require_durability(current)
+ validate_compose(plan, env_path)
+ _run(
+ [
+ "docker", "compose", "--env-file", str(env_path),
+ "-f", "docker-compose.yml", "-f", TUNED_COMPOSE_FILE,
+ "up", "-d", "--wait", "--no-deps", "--force-recreate", "postgres",
+ ]
+ )
+ applied = _settings(_postgres_snapshot())
+ proposed = plan["proposed"]
+ for field in ("max_wal_size_bytes", "wal_buffers_bytes"):
+ if _integer(applied.get(field), field) != _integer(proposed.get(field), field):
+ raise TuningPlanError(f"PostgreSQL did not apply {field}")
+ for field in DURABILITY_SETTINGS:
+ if str(applied.get(field, "")).lower() != str(proposed.get(field, "")).lower():
+ raise TuningPlanError(f"PostgreSQL did not preserve {field}")
+
+
+def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
+ """Parse the tuning procedure command line."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ plan_parser = subparsers.add_parser("plan")
+ plan_parser.add_argument("--sample-seconds", type=float, required=True)
+ plan_parser.add_argument("--output", type=Path, required=True)
+ validate_parser = subparsers.add_parser("validate")
+ validate_parser.add_argument("--plan", type=Path, required=True)
+ validate_parser.add_argument("--env-output", type=Path, required=True)
+ apply_parser = subparsers.add_parser("apply")
+ apply_parser.add_argument("--plan", type=Path, required=True)
+ apply_parser.add_argument("--env-output", type=Path, required=True)
+ apply_parser.add_argument("--approve-plan-id", required=True)
+ rollback_parser = subparsers.add_parser("rollback")
+ rollback_parser.add_argument("--plan", type=Path, required=True)
+ rollback_parser.add_argument("--env-output", type=Path, required=True)
+ rollback_parser.add_argument("--approve-plan-id", required=True)
+ return parser.parse_args(argv)
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """Execute the selected measure, validate, apply, or rollback phase."""
+ args = parse_args(argv)
+ if args.command == "plan":
+ plan = build_plan(measure(args.sample_seconds))
+ args.output.write_text(json.dumps(plan, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ print(plan["plan_id"])
+ return 0
+ plan = _load_plan(args.plan)
+ if args.command == "validate":
+ validate_compose(plan, args.env_output)
+ return 0
+ if args.command == "rollback":
+ rollback_plan = dict(plan)
+ rollback_plan["proposed"] = plan["rollback"]
+ rollback_plan["rollback"] = plan["proposed"]
+ controlled_restart(rollback_plan, args.env_output, args.approve_plan_id)
+ return 0
+ controlled_restart(plan, args.env_output, args.approve_plan_id)
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover - main() is exercised directly.
+ raise SystemExit(main())
diff --git a/tests/test_postgres_tuning_plan.py b/tests/test_postgres_tuning_plan.py
new file mode 100644
index 000000000..97b165e67
--- /dev/null
+++ b/tests/test_postgres_tuning_plan.py
@@ -0,0 +1,475 @@
+"""Evidence-derived PostgreSQL Compose tuning procedure contracts."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+from types import ModuleType
+
+import pytest
+
+_ROOT = Path(__file__).resolve().parents[1]
+_SCRIPT = _ROOT / "scripts" / "plan_postgres_tuning.py"
+
+
+def _load_module() -> ModuleType:
+ spec = importlib.util.spec_from_file_location("plan_postgres_tuning", _SCRIPT)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+tuning = _load_module()
+
+
+def _snapshot(**changes: object) -> dict[str, object]:
+ snapshot: dict[str, object] = {
+ "captured_at": "2026-08-26T00:00:00Z",
+ "server_version_num": 160014,
+ "wal_stats_reset": "2026-08-24T00:00:00Z",
+ "checkpoint_stats_reset": "2026-08-24T00:00:00Z",
+ "wal_bytes": "0",
+ "wal_buffers_full": 0,
+ "checkpoints_timed": 0,
+ "checkpoints_req": 0,
+ "wal_segment_size_bytes": 16 * tuning.MIB,
+ "settings": {
+ "checkpoint_timeout_seconds": 300,
+ "max_wal_size_bytes": 1024 * tuning.MIB,
+ "min_wal_size_bytes": 80 * tuning.MIB,
+ "wal_buffers_bytes": 4 * tuning.MIB,
+ "shared_buffers_bytes": 128 * tuning.MIB,
+ "maintenance_work_mem_bytes": 64 * tuning.MIB,
+ "effective_io_concurrency": 1,
+ "maintenance_io_concurrency": 10,
+ "wal_compression": "off",
+ "fsync": "on",
+ "full_page_writes": "on",
+ "synchronous_commit": "on",
+ },
+ }
+ snapshot.update(changes)
+ return snapshot
+
+
+def _observation(before: dict[str, object], after: dict[str, object], **changes: object):
+ values = {
+ "before": before,
+ "after": after,
+ "elapsed_seconds": 60.0,
+ "container_memory_limit_bytes": 8 * 1024 * tuning.MIB,
+ "data_filesystem_free_bytes": 100 * 1024 * tuning.MIB,
+ "pg_wal_bytes": 1024 * tuning.MIB,
+ }
+ values.update(changes)
+ return tuning.Observation(**values)
+
+
+def test_plan_uses_measured_checkpoint_interval_and_segment_boundary() -> None:
+ before = _snapshot()
+ after = _snapshot(
+ wal_bytes=str(600 * tuning.MIB),
+ wal_buffers_full=12,
+ checkpoints_req=4,
+ )
+
+ plan = tuning.build_plan(_observation(before, after))
+
+ # 600 MiB / 60 s * 300 s = 3000 MiB, rounded to a 16 MiB WAL segment.
+ assert plan["proposed"]["max_wal_size_bytes"] == 3008 * tuning.MIB
+ assert plan["proposed"]["wal_buffers_bytes"] == 16 * tuning.MIB
+ assert plan["evidence"]["checkpoints_requested"] == 4
+ assert plan["retained_unmeasured"]["effective_io_concurrency"] == 1
+ assert plan["retained_unmeasured"]["wal_compression"] == "off"
+
+
+def test_plan_retains_settings_when_observation_has_no_pressure() -> None:
+ before = _snapshot()
+ after = _snapshot(checkpoints_timed=1)
+
+ plan = tuning.build_plan(_observation(before, after))
+
+ assert plan["proposed"]["max_wal_size_bytes"] == 1024 * tuning.MIB
+ assert plan["proposed"]["wal_buffers_bytes"] == 4 * tuning.MIB
+
+
+def test_plan_keeps_historical_pressure_distinct_from_idle_sample() -> None:
+ before = _snapshot(wal_bytes=str(287 * 1024 * tuning.MIB), wal_buffers_full=7_404_489)
+ after = _snapshot(
+ captured_at="2026-08-26T00:00:00Z",
+ wal_bytes=str(287 * 1024 * tuning.MIB),
+ wal_buffers_full=7_404_489,
+ checkpoints_req=21_990,
+ checkpoints_timed=257,
+ )
+
+ plan = tuning.build_plan(_observation(before, after))
+
+ assert plan["evidence"]["wal_bytes"] == 0
+ assert plan["evidence"]["sample_wal_bytes_per_second"] == 0
+ assert plan["evidence"]["cumulative_wal_bytes_per_second"] > 0
+ # The cumulative average alone does not justify exceeding the current 1 GiB.
+ assert plan["proposed"]["max_wal_size_bytes"] == 1024 * tuning.MIB
+ assert plan["proposed"]["wal_buffers_bytes"] == 16 * tuning.MIB
+
+
+@pytest.mark.parametrize(
+ ("before", "after", "message"),
+ [
+ (_snapshot(), _snapshot(wal_stats_reset="later"), "wal_stats_reset"),
+ (_snapshot(wal_bytes="2"), _snapshot(wal_bytes="1"), "wal_bytes decreased"),
+ (
+ _snapshot(settings={**_snapshot()["settings"], "fsync": "off"}),
+ _snapshot(settings={**_snapshot()["settings"], "fsync": "off"}),
+ "durability setting fsync",
+ ),
+ ],
+)
+def test_plan_rejects_incomparable_or_unsafe_evidence(
+ before: dict[str, object], after: dict[str, object], message: str
+) -> None:
+ with pytest.raises(tuning.TuningPlanError, match=message):
+ tuning.build_plan(_observation(before, after))
+
+
+def test_plan_rejects_exact_additional_wal_beyond_free_space() -> None:
+ before = _snapshot()
+ after = _snapshot(wal_bytes=str(600 * tuning.MIB))
+
+ with pytest.raises(tuning.TuningPlanError, match="free space"):
+ tuning.build_plan(
+ _observation(before, after, data_filesystem_free_bytes=1983 * tuning.MIB)
+ )
+
+
+def test_environment_preserves_durability_and_supports_rollback() -> None:
+ before = _snapshot()
+ after = _snapshot(wal_buffers_full=1)
+ plan = tuning.build_plan(_observation(before, after))
+
+ proposed = tuning.plan_environment(plan)
+ rollback = tuning.plan_environment(plan, rollback=True)
+
+ assert "POSTGRES_TUNED_WAL_BUFFERS=16MB" in proposed
+ assert "POSTGRES_TUNED_WAL_BUFFERS=4MB" in rollback
+ assert "POSTGRES_TUNED_FSYNC=on" in proposed
+ assert "POSTGRES_TUNED_FULL_PAGE_WRITES=on" in proposed
+ assert "POSTGRES_TUNED_SYNCHRONOUS_COMMIT=on" in proposed
+
+
+def test_environment_preserves_retained_block_aligned_wal_buffers() -> None:
+ settings = {**_snapshot()["settings"], "wal_buffers_bytes": 640 * tuning.KIB}
+ plan = tuning.build_plan(
+ _observation(_snapshot(settings=settings), _snapshot(settings=settings))
+ )
+
+ assert "POSTGRES_TUNED_WAL_BUFFERS=640kB" in tuning.plan_environment(plan)
+
+
+def test_compose_overlay_has_no_unmeasured_tuning_or_durability_relaxation() -> None:
+ overlay = (_ROOT / "docker-compose.postgres-tuned.yml").read_text(encoding="utf-8")
+
+ assert "max_wal_size=${POSTGRES_TUNED_MAX_WAL_SIZE:" in overlay
+ assert "wal_buffers=${POSTGRES_TUNED_WAL_BUFFERS:" in overlay
+ assert "fsync=${POSTGRES_TUNED_FSYNC:" in overlay
+ assert "shared_buffers" not in overlay
+ assert "maintenance_work_mem" not in overlay
+ assert "effective_io_concurrency" not in overlay
+ assert "wal_compression" not in overlay
+
+
+def test_measure_uses_explicit_window_and_container_evidence(monkeypatch) -> None:
+ snapshots = iter([_snapshot(), _snapshot(wal_bytes="10")])
+ sleeps: list[float] = []
+ monotonic = iter([100.0, 112.5])
+ monkeypatch.setattr(tuning, "_postgres_snapshot", lambda: next(snapshots))
+ monkeypatch.setattr(
+ tuning, "_container_resources", lambda: (2048 * tuning.MIB, 4096, 1024)
+ )
+ monkeypatch.setattr(tuning.time, "monotonic", lambda: next(monotonic))
+
+ observation = tuning.measure(12.5, sleeper=sleeps.append)
+
+ assert sleeps == [12.5]
+ assert observation.elapsed_seconds == 12.5
+ assert observation.container_memory_limit_bytes == 2048 * tuning.MIB
+
+
+def test_controlled_restart_checks_old_and_new_settings(monkeypatch, tmp_path: Path) -> None:
+ plan = tuning.build_plan(
+ _observation(_snapshot(), _snapshot(wal_buffers_full=1))
+ )
+ snapshots = iter(
+ [
+ _snapshot(),
+ _snapshot(
+ settings={
+ **_snapshot()["settings"],
+ "wal_buffers_bytes": 16 * tuning.MIB,
+ }
+ ),
+ ]
+ )
+ commands: list[list[str]] = []
+ monkeypatch.setattr(tuning, "_postgres_snapshot", lambda: next(snapshots))
+ monkeypatch.setattr(
+ tuning, "_run", lambda command, **_kwargs: commands.append(list(command)) or ""
+ )
+
+ tuning.controlled_restart(plan, tmp_path / "tuning.env", plan["plan_id"])
+
+ assert any("config" in command and "--quiet" in command for command in commands)
+ apply = next(command for command in commands if "up" in command)
+ assert "--wait" in apply
+ assert "--force-recreate" in apply
+
+
+def test_controlled_restart_rejects_stale_plan_before_compose(monkeypatch, tmp_path: Path) -> None:
+ plan = tuning.build_plan(_observation(_snapshot(), _snapshot()))
+ stale = _snapshot(
+ settings={
+ **_snapshot()["settings"],
+ "max_wal_size_bytes": 2048 * tuning.MIB,
+ }
+ )
+ monkeypatch.setattr(tuning, "_postgres_snapshot", lambda: stale)
+ monkeypatch.setattr(
+ tuning,
+ "_run",
+ lambda *_args, **_kwargs: pytest.fail("Compose must not run for a stale plan"),
+ )
+
+ with pytest.raises(tuning.TuningPlanError, match="no longer matches"):
+ tuning.controlled_restart(plan, tmp_path / "tuning.env", plan["plan_id"])
+
+
+@pytest.mark.parametrize("value", [None, "bad"])
+def test_integer_rejects_non_integer(value: object) -> None:
+ with pytest.raises(tuning.TuningPlanError, match="must be an integer"):
+ tuning._integer(value, "value")
+
+
+def test_integer_rejects_negative() -> None:
+ with pytest.raises(tuning.TuningPlanError, match="must not be negative"):
+ tuning._integer(-1, "value")
+
+
+@pytest.mark.parametrize(
+ ("changes", "message"),
+ [
+ ({"elapsed_seconds": 0}, "elapsed_seconds"),
+ ({"data_filesystem_free_bytes": -1}, "filesystem measurements"),
+ ({"pg_wal_bytes": -1}, "filesystem measurements"),
+ ({"container_memory_limit_bytes": 1}, "memory limit"),
+ ],
+)
+def test_plan_rejects_invalid_observation_resources(
+ changes: dict[str, object], message: str
+) -> None:
+ before = _snapshot()
+ after = _snapshot(wal_buffers_full=1)
+ with pytest.raises(tuning.TuningPlanError, match=message):
+ tuning.build_plan(_observation(before, after, **changes))
+
+
+@pytest.mark.parametrize(
+ ("before", "after", "message"),
+ [
+ (_snapshot(), _snapshot(server_version_num=170000), "PostgreSQL 16"),
+ (_snapshot(), _snapshot(settings=None), "settings are unavailable"),
+ (
+ _snapshot(),
+ _snapshot(settings={**_snapshot()["settings"], "wal_compression": "on"}),
+ "settings changed",
+ ),
+ (_snapshot(), _snapshot(wal_segment_size_bytes=0), "wal_segment_size"),
+ (_snapshot(), _snapshot(wal_segment_size_bytes=tuning.MIB + 1), "wal_segment_size"),
+ (
+ _snapshot(settings={**_snapshot()["settings"], "checkpoint_timeout_seconds": 0}),
+ _snapshot(settings={**_snapshot()["settings"], "checkpoint_timeout_seconds": 0}),
+ "checkpoint_timeout",
+ ),
+ ],
+)
+def test_plan_rejects_unsupported_database_evidence(
+ before: dict[str, object], after: dict[str, object], message: str
+) -> None:
+ with pytest.raises(tuning.TuningPlanError, match=message):
+ tuning.build_plan(_observation(before, after))
+
+
+def test_environment_rejects_missing_or_misaligned_values() -> None:
+ with pytest.raises(tuning.TuningPlanError, match="settings are unavailable"):
+ tuning.plan_environment({})
+ with pytest.raises(tuning.TuningPlanError, match="whole MiB"):
+ tuning.plan_environment(
+ {
+ "proposed": {
+ "max_wal_size_bytes": tuning.MIB + 1,
+ "wal_buffers_bytes": tuning.MIB,
+ "fsync": "on",
+ "full_page_writes": "on",
+ "synchronous_commit": "on",
+ }
+ }
+ )
+
+
+def test_durability_value_rejects_unsupported_mode() -> None:
+ with pytest.raises(tuning.TuningPlanError, match="unsupported durability"):
+ tuning._durability_value({"synchronous_commit": "off"}, "synchronous_commit")
+
+
+def test_run_returns_stdout_and_reports_command_failure(monkeypatch) -> None:
+ monkeypatch.setattr(
+ tuning.subprocess,
+ "run",
+ lambda *_args, **_kwargs: SimpleNamespace(returncode=0, stdout=" ok \n", stderr=""),
+ )
+ assert tuning._run(["command"]) == "ok"
+ monkeypatch.setattr(
+ tuning.subprocess,
+ "run",
+ lambda *_args, **_kwargs: SimpleNamespace(returncode=1, stdout="", stderr="bad"),
+ )
+ with pytest.raises(tuning.TuningPlanError, match="bad"):
+ tuning._run(["command"])
+
+
+def test_snapshot_reads_compose_environment_and_json(monkeypatch) -> None:
+ outputs = iter(["app-user", "app-db", '{"settings": {}}'])
+ commands: list[list[str]] = []
+
+ def fake_run(command, **_kwargs):
+ commands.append(list(command))
+ return next(outputs)
+
+ monkeypatch.setattr(tuning, "_run", fake_run)
+ assert tuning._postgres_snapshot() == {"settings": {}}
+ assert "app-user" in commands[-1]
+ assert "app-db" in commands[-1]
+
+ monkeypatch.setattr(tuning, "_run", lambda *_args, **_kwargs: "[]")
+ with pytest.raises(tuning.TuningPlanError, match="JSON object"):
+ tuning._postgres_snapshot()
+
+
+def test_container_resource_measurement_handles_cgroup_limit(monkeypatch) -> None:
+ monkeypatch.setattr(tuning, "_run", lambda *_args, **_kwargs: "max\n4096\n1024")
+ assert tuning._container_resources() == (None, 4096 * 1024, 1024 * 1024)
+ monkeypatch.setattr(tuning, "_run", lambda *_args, **_kwargs: "8192\n4096\n1024")
+ assert tuning._container_resources() == (8192, 4096 * 1024, 1024 * 1024)
+ monkeypatch.setattr(tuning, "_run", lambda *_args, **_kwargs: "incomplete")
+ with pytest.raises(tuning.TuningPlanError, match="incomplete"):
+ tuning._container_resources()
+
+
+def test_measure_rejects_non_positive_window() -> None:
+ with pytest.raises(tuning.TuningPlanError, match="sample_seconds"):
+ tuning.measure(0)
+
+
+@pytest.mark.parametrize(
+ ("snapshot", "message"),
+ [
+ ({"captured_at": "bad", "wal_stats_reset": "also-bad"}, "window is invalid"),
+ (
+ {"captured_at": "2026-08-24T00:00:00Z", "wal_stats_reset": "2026-08-24T00:00:00Z"},
+ "must be positive",
+ ),
+ ],
+)
+def test_cumulative_window_rejects_invalid_timestamps(
+ snapshot: dict[str, str], message: str
+) -> None:
+ with pytest.raises(tuning.TuningPlanError, match=message):
+ tuning._seconds_since(snapshot, "wal_stats_reset")
+
+
+def test_load_plan_authenticates_content(tmp_path: Path) -> None:
+ plan = tuning.build_plan(_observation(_snapshot(), _snapshot()))
+ path = tmp_path / "plan.json"
+ path.write_text(json.dumps(plan), encoding="utf-8")
+ assert tuning._load_plan(path) == plan
+ path.write_text('{"plan_id": "wrong"}', encoding="utf-8")
+ with pytest.raises(tuning.TuningPlanError, match="does not match"):
+ tuning._load_plan(path)
+ path.write_text("[]", encoding="utf-8")
+ with pytest.raises(tuning.TuningPlanError, match="incomplete"):
+ tuning._load_plan(path)
+
+
+def test_controlled_restart_rejects_approval_and_missing_rollback(tmp_path: Path) -> None:
+ plan = tuning.build_plan(_observation(_snapshot(), _snapshot()))
+ with pytest.raises(tuning.TuningPlanError, match="approve-plan-id"):
+ tuning.controlled_restart(plan, tmp_path / "env", "wrong")
+ invalid = {**plan, "rollback": None}
+ with pytest.MonkeyPatch.context() as patch:
+ patch.setattr(tuning, "_postgres_snapshot", _snapshot)
+ with pytest.raises(tuning.TuningPlanError, match="rollback settings"):
+ tuning.controlled_restart(invalid, tmp_path / "env", plan["plan_id"])
+
+
+@pytest.mark.parametrize(
+ ("applied_changes", "message"),
+ [
+ ({"wal_buffers_bytes": 8 * tuning.MIB}, "did not apply wal_buffers"),
+ ({"synchronous_commit": "remote_apply"}, "did not preserve synchronous_commit"),
+ ],
+)
+def test_controlled_restart_verifies_applied_settings(
+ monkeypatch, tmp_path: Path, applied_changes: dict[str, object], message: str
+) -> None:
+ plan = tuning.build_plan(_observation(_snapshot(), _snapshot(wal_buffers_full=1)))
+ applied = _snapshot(
+ settings={
+ **_snapshot()["settings"],
+ "wal_buffers_bytes": 16 * tuning.MIB,
+ **applied_changes,
+ }
+ )
+ snapshots = iter([_snapshot(), applied])
+ monkeypatch.setattr(tuning, "_postgres_snapshot", lambda: next(snapshots))
+ monkeypatch.setattr(tuning, "_run", lambda *_args, **_kwargs: "")
+ with pytest.raises(tuning.TuningPlanError, match=message):
+ tuning.controlled_restart(plan, tmp_path / "env", plan["plan_id"])
+
+
+def test_main_plan_validate_apply_and_rollback(monkeypatch, tmp_path: Path, capsys) -> None:
+ plan = tuning.build_plan(_observation(_snapshot(), _snapshot()))
+ plan_path = tmp_path / "plan.json"
+ env_path = tmp_path / "env"
+ calls: list[tuple[str, str]] = []
+ monkeypatch.setattr(tuning, "measure", lambda _seconds: _observation(_snapshot(), _snapshot()))
+ assert tuning.main(["plan", "--sample-seconds", "1", "--output", str(plan_path)]) == 0
+ assert capsys.readouterr().out.strip() == json.loads(plan_path.read_text())["plan_id"]
+ monkeypatch.setattr(tuning, "validate_compose", lambda *_args: calls.append(("validate", "")))
+ assert tuning.main(["validate", "--plan", str(plan_path), "--env-output", str(env_path)]) == 0
+
+ def fake_restart(selected, _env, approval):
+ calls.append(("restart", approval))
+ assert selected["plan_id"] == plan["plan_id"]
+
+ monkeypatch.setattr(tuning, "controlled_restart", fake_restart)
+ for command in ("apply", "rollback"):
+ assert (
+ tuning.main(
+ [
+ command,
+ "--plan",
+ str(plan_path),
+ "--env-output",
+ str(env_path),
+ "--approve-plan-id",
+ plan["plan_id"],
+ ]
+ )
+ == 0
+ )
+ assert calls[0][0] == "validate"
+ assert [item[0] for item in calls].count("restart") == 2
From a0be16461c9b6845d288e7ee3fb6c0170fdad19d Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 11:16:21 +0900
Subject: [PATCH 083/393] fix: report only observed backfill counts
---
backend/app/main.py | 2 +-
backend/app/post_content_queue.py | 11 ++++-------
scripts/queue_post_content_backfill.py | 2 +-
tests/test_post_content_backfill_endpoint.py | 6 ++----
tests/test_post_content_queue.py | 5 +----
tests/test_queue_post_content_backfill_script.py | 2 +-
6 files changed, 10 insertions(+), 18 deletions(-)
diff --git a/backend/app/main.py b/backend/app/main.py
index 5c90eda4a..efd8494eb 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -821,7 +821,7 @@ async def queue_post_content_backfill(
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
valkey: redis.Redis = Depends(get_valkey),
-) -> dict[str, int | bool]:
+) -> dict[str, int]:
"""Queue one bounded corpus page and return before semantic work runs."""
_require_post_admin(account)
settings = load_settings()
diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py
index dfc9181d9..2cc7b361d 100644
--- a/backend/app/post_content_queue.py
+++ b/backend/app/post_content_queue.py
@@ -315,7 +315,7 @@ async def enqueue_post_content_backfill(
limit: int,
require_embedding: bool,
require_structure: bool,
-) -> dict[str, int | bool]:
+) -> dict[str, int]:
"""Durably enqueue one bounded page of eligible incomplete source posts.
PostgreSQL is committed before Valkey is touched. A missing wake-up is
@@ -376,7 +376,6 @@ async def enqueue_post_content_backfill(
for update of post skip locked
"""
requests: list[PostContentJobRequest] = []
- has_more = False
async with pool.acquire() as conn:
async with conn.transaction():
# Safe SQL: the eligibility predicate is an immutable schema fragment; values are bound.
@@ -385,10 +384,9 @@ async def enqueue_post_content_backfill(
SUCCEEDED,
require_embedding,
require_structure,
- limit + 1,
+ limit,
)
- has_more = len(rows) > limit
- for row in rows[:limit]:
+ for row in rows:
post_id = str(row["post_id"])
complete = await post_content_is_complete(
conn,
@@ -414,11 +412,10 @@ async def enqueue_post_content_backfill(
):
published += 1
return {
- "selected_posts": min(len(rows), limit),
+ "selected_posts": len(rows),
"queued_posts": len(requests),
"published_events": published,
"recovery_pending": len(requests) - published,
- "has_more": has_more,
}
diff --git a/scripts/queue_post_content_backfill.py b/scripts/queue_post_content_backfill.py
index c94d3ca97..d1b7119a3 100644
--- a/scripts/queue_post_content_backfill.py
+++ b/scripts/queue_post_content_backfill.py
@@ -44,7 +44,7 @@ async def queue_post_content_backfill(
valkey_url: str,
*,
limit: int,
-) -> dict[str, int | bool]:
+) -> dict[str, int]:
"""Queue one bounded page through the shared durable producer."""
if not 1 <= limit <= 200:
raise ValueError("limit must be between 1 and 200")
diff --git a/tests/test_post_content_backfill_endpoint.py b/tests/test_post_content_backfill_endpoint.py
index 0be04bd8d..3e81bf8eb 100644
--- a/tests/test_post_content_backfill_endpoint.py
+++ b/tests/test_post_content_backfill_endpoint.py
@@ -51,14 +51,13 @@ def test_backfill_endpoint_only_enqueues_durable_work(
"""The accepted response delegates once and never invokes a provider."""
observed: dict[str, object] = {}
- async def enqueue(pool: object, valkey: object, **kwargs: object) -> dict[str, int | bool]:
+ async def enqueue(pool: object, valkey: object, **kwargs: object) -> dict[str, int]:
observed.update(pool=pool, valkey=valkey, **kwargs)
return {
"selected_posts": 1,
"queued_posts": 1,
"published_events": 1,
"recovery_pending": 0,
- "has_more": False,
}
monkeypatch.setattr(main, "enqueue_post_content_backfill", enqueue)
@@ -97,14 +96,13 @@ def test_backfill_endpoint_does_not_require_missing_model_evidence(
"""An unwired orchestrator remains unavailable instead of being fabricated."""
observed: dict[str, object] = {}
- async def enqueue(_pool: object, _valkey: object, **kwargs: object) -> dict[str, int | bool]:
+ async def enqueue(_pool: object, _valkey: object, **kwargs: object) -> dict[str, int]:
observed.update(kwargs)
return {
"selected_posts": 0,
"queued_posts": 0,
"published_events": 0,
"recovery_pending": 0,
- "has_more": False,
}
monkeypatch.setattr(main, "enqueue_post_content_backfill", enqueue)
diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py
index 0377067ce..2c820cbfb 100644
--- a/tests/test_post_content_queue.py
+++ b/tests/test_post_content_queue.py
@@ -63,11 +63,10 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, str]]:
assert "source_deleted_flag" in query
assert "job.post_id is null or job.status_code = $1" in query
assert "for update of post skip locked" in query.lower()
- assert args == (SUCCEEDED, True, True, 3)
+ assert args == (SUCCEEDED, True, True, 2)
return [
{"post_id": "00000000-0000-0000-0000-000000000001", "post_body": "one"},
{"post_id": "00000000-0000-0000-0000-000000000002", "post_body": "two"},
- {"post_id": "00000000-0000-0000-0000-000000000003", "post_body": "three"},
]
class Acquire:
@@ -113,7 +112,6 @@ async def publish(*_args: object, **_kwargs: object) -> str | None:
"queued_posts": 2,
"published_events": 1,
"recovery_pending": 1,
- "has_more": True,
}
@@ -172,7 +170,6 @@ async def ensure(
"queued_posts": 0,
"published_events": 0,
"recovery_pending": 0,
- "has_more": False,
}
diff --git a/tests/test_queue_post_content_backfill_script.py b/tests/test_queue_post_content_backfill_script.py
index d7835991c..c76589644 100644
--- a/tests/test_queue_post_content_backfill_script.py
+++ b/tests/test_queue_post_content_backfill_script.py
@@ -59,7 +59,7 @@ async def aclose(self) -> None:
async def create_pool(*_args: object, **_kwargs: object) -> Pool:
return pool
- async def enqueue(_pool: object, _client: object, **kwargs: object) -> dict[str, int | bool]:
+ async def enqueue(_pool: object, _client: object, **kwargs: object) -> dict[str, int]:
assert (_pool, _client) == (pool, client)
assert kwargs == {
"limit": 12,
From 2a91907956f44acd39776cbb5bc4508c356125f9 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 11:30:40 +0900
Subject: [PATCH 084/393] fix(ask): find related source evidence automatically
---
CHANGELOG.md | 6 +++
backend/app/post_chat_ingestion.py | 19 +++++++-
.../adr/0206-evidence-operations-dashboard.md | 9 ++--
docs/product-technical-gap-baseline.md | 9 +++-
frontend/src/App.css | 47 +++++++++++++++++++
frontend/src/App.tsx | 34 +++++++++-----
.../components/OperationsDashboard.test.tsx | 2 +-
.../src/components/OperationsDashboard.tsx | 6 +--
tests/test_post_chat_ingestion.py | 31 ++++++++++++
9 files changed, 141 insertions(+), 22 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9efed2cde..41ac253fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@ All notable changes to this project are documented here. Format follows
### Changed
+- Ask Agent now searches posts with the same persisted semantic project key as
+ part of its authorized evidence window before re-analysis. Missing evidence
+ remains a retry state rather than a request for the reader to attach an
+ original, and the composer now uses an accessible form with a stable action
+ and separate progress status.
+
- Docker Compose now has one canonical `lineageweave` project containing the
complete synthetic product stack. The backend receives the existing TEPP API
credential contract, and the OIDC smoke target installs its declared backend
diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py
index 2777e8c66..6d9a9daa4 100644
--- a/backend/app/post_chat_ingestion.py
+++ b/backend/app/post_chat_ingestion.py
@@ -270,6 +270,23 @@ async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> Linked
)
sibling_post_ids = list({str(row["post_id"]) for row in sibling_rows} | {post_id})
+ project_rows = await conn.fetch(
+ "select distinct project_key from post_project_mention where post_id = $1",
+ post_id,
+ )
+ project_keys = [str(row["project_key"]) for row in project_rows]
+ project_sibling_ids: set[str] = set()
+ if project_keys:
+ project_sibling_rows = await conn.fetch(
+ "select distinct post_id from post_project_mention "
+ "where project_key = any($1::text[])",
+ project_keys,
+ )
+ project_sibling_ids = {
+ str(row["post_id"]) for row in project_sibling_rows
+ } - {post_id}
+ sibling_post_ids = list(set(sibling_post_ids) | project_sibling_ids)
+
edges = await load_visible_subgraph(conn, sibling_post_ids)
start = node_key(NODE_POST, post_id)
scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start)
@@ -278,7 +295,7 @@ async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> Linked
node_id
for key, _ in related
if (node_id := parse_node_key(key)[1]) and parse_node_key(key)[0] == NODE_POST
- ) - {post_id}
+ ).union(project_sibling_ids) - {post_id}
return LinkedPostIds(direct=direct_ids - {post_id}, indirect=indirect_ids - direct_ids)
diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
index 5e48ba6c2..349147137 100644
--- a/docs/adr/0206-evidence-operations-dashboard.md
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -63,7 +63,9 @@ provenance.
absent from the authorized corpus.
The analysis input reuses the post-chat source assembler: focal post first,
then bounded Event Lineage and semantic-neighborhood posts after the same
- corporate-entity/process-unit ABAC check. Every classification and fact
+ corporate-entity/process-unit ABAC check. The semantic window includes posts
+ carrying the same persisted `post_project_mention.project_key`; display-name
+ similarity and keyword matching do not create that link. Every classification and fact
persists its evidence post id and the SHA-256 of the exact numbered input
document. A span that does not occur in that identified document rejects
the whole provider response; linked evidence is never rewritten as focal
@@ -71,8 +73,9 @@ provenance.
8. Claim-investigation and rebid/handover panels include positively classified
cases and show extracted answers plus cited spans. A required answer that
the source does not support is stored in the normalized
- `operations_case_missing_fact` relation as an explicit missing fact, so the
- next action is collection or human correction rather than keyword guessing.
+ `operations_case_missing_fact` relation as an explicit retry state while the
+ system searches the authorized semantic source window and re-analyzes the
+ case. The reader is not asked to attach the source manually.
A provider result is invalid unless every required question is represented
exactly once as either a cited supported fact or an explicit missing fact;
a fact cannot be both. Missing facts carry no invented value or evidence
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index c7ddd5308..016769cd4 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -28,7 +28,7 @@ containers while preserving named volumes.
| Apple-Silicon mathematical acceleration | ADR 0226 macOS-native Rust owner service with authenticated MLX Metal execution receipts | Normative boundary accepted; TEPP, fast-mlsirm, and RankWeave owner implementations and actual Metal parity receipts remain required before activation |
| Ask answer citation-to-event navigation | ADR 0225; authorized cited source, observed source clock, focused evidence layer, and full-post navigation | Stacked candidate renders numbered answer citations and chronologically ordered evidence cards with bidirectional focus. Event and record clocks stay distinct; this list does not claim a Project Journey. Storybook and component interaction evidence are included; authenticated runtime screenshots remain required at the exact candidate head. |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
-| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending |
+| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback. Post-scoped evidence collection now also follows exact persisted `project_key` membership, so the system searches authorized related originals before re-analysis instead of asking the reader to attach them. Focused backend tests pass; authenticated exact-head runtime acceptance remains pending. |
| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending |
| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet |
| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open at `063f10f3`; stacked #251–#254 provide fail-closed input validation, full joint precision, deterministic joint plausible-value draws, and the canonical research register, while complete provenance assembly remains gated. fast-mlsirm PR #1418 validates the Rust consumer envelope but intentionally returns `EstimatorUnavailable` until the scientific estimator lands. Runtime therefore remains honestly unavailable with no local Python or fallback score. |
@@ -87,6 +87,13 @@ required before protected delivery can be claimed.
Authenticated authorized-corpus acceptance remains separate and may return
only aggregate, non-identifying evidence to this repository.
+The 2026-08-26 canonical runtime audit found the Ask composer visually
+compressed even though its asynchronous enqueue path remained responsive. The
+current stacked candidate replaces that loose control row with a labeled form,
+a stable submit action, and a separate live status. Component screenshot
+acceptance remains pending until the candidate image is rebuilt. This is not
+protected-main delivery evidence.
+
The current candidate was also re-rendered from the isolated synthetic stack
at 1440×1100 and 402×1200 after the touch-target repair. Both viewports had
document width equal to viewport width, no browser console/page errors, and no
diff --git a/frontend/src/App.css b/frontend/src/App.css
index f9bd2ee0b..020bc246d 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -465,6 +465,53 @@
opacity: 0.8;
}
+.ask-agent-panel {
+ width: min(100% - 2rem, 70rem);
+ min-height: 36rem;
+ margin: 0 auto;
+ padding: 3rem 0;
+}
+
+.ask-agent-composer {
+ display: grid;
+ gap: 0.75rem;
+ max-width: 52rem;
+ padding: 1rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-panel);
+ background: var(--surface);
+}
+
+.ask-agent-source {
+ display: grid;
+ gap: 0.5rem;
+ font-weight: 600;
+}
+
+.ask-agent-source textarea {
+ width: 100%;
+ min-height: 7rem;
+ resize: vertical;
+ padding: 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-control);
+ background: var(--surface);
+ color: inherit;
+ font: inherit;
+ line-height: 1.5;
+}
+
+.ask-agent-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+}
+
+.ask-agent-actions [role="status"] {
+ color: var(--text-muted);
+}
+
.lineage-list {
list-style: none;
padding: 0;
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 16b814666..87c847692 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4848,23 +4848,31 @@ function AskAgentPanel({
}
return (
-
+
{t("Evidence-grounded questions")}
{t("Ask Agent")}
{t("Questions use authorized posts and their evidence.")}
{error ? {error}
: null}
-
- {t("Ask a question")}
-
- void handleAsk()} disabled={asking || !question.trim()}>
- {asking ? t("Asking...") : t("Ask")}
-
+
{answer && (
{t("Answer")}
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index 6f6118e89..27bc1abad 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -55,7 +55,7 @@ describe("OperationsDashboardView", () => {
expect(screen.getByText("3 Event · 2글")).toBeInTheDocument();
expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument();
expect(screen.getByText("원인 수주")).toBeInTheDocument();
- expect(screen.getByText(/수주 Pool: 권한 범위 내 근거가 없습니다/)).toBeInTheDocument();
+ expect(screen.getByText(/수주 Pool: 관련 원문을 찾고 다시 분석하고 있습니다/)).toBeInTheDocument();
expect(screen.getByText("2일 3시간 30분 0초")).toBeInTheDocument();
expect(screen.getByText(/진행 중 1건 · 종료 확인 0건/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" }));
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index 6e10329ca..217fc03c9 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -108,7 +108,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{!externalOnly ? (
관측된 처리 구간
- 임의 지연 기준 없이, 시작·종료 Event가 모두 확인된 구간만 경과 시간을 계산합니다.
+ 시작과 종료 Event가 확인된 항목의 경과 시간을 비교하세요.
{data.lifecycle_metrics.map((metric) => (
@@ -172,7 +172,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.missing_facts.length ? (
추가 확인 필요
- {item.missing_facts.map((fact) => {fact.fact_type_label}: 권한 범위 내 근거가 없습니다. 관련 원문을 연결하세요. )}
+ {item.missing_facts.map((fact) => {fact.fact_type_label}: 관련 원문을 찾고 다시 분석하고 있습니다. )}
) : null}
onOpenPost(item.evidence_post_id)}>분류 근거 글 열기
@@ -228,7 +228,7 @@ export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDa
{dimensionLabels[context.dimension_code]} · {context.context_label}
- 값이 같으면 동점이며, 순번이나 임의 가중치를 추가하지 않습니다.
+ 영향도와 불확실성을 함께 비교하고 같은 값은 동점으로 확인하세요.
Event 발생일 상태 Model influence 불확실성 소속 근거 원문
{context.influences.map((influence) => (
diff --git a/tests/test_post_chat_ingestion.py b/tests/test_post_chat_ingestion.py
index 6626d85ac..27fbab68a 100644
--- a/tests/test_post_chat_ingestion.py
+++ b/tests/test_post_chat_ingestion.py
@@ -11,6 +11,7 @@
cited_post_images,
fetch_persisted_chat,
fetch_persisted_chats,
+ find_linked_post_ids,
gather_chat_sources,
normalize_chat_question,
persist_post_chat,
@@ -69,6 +70,36 @@ async def fetch(self, _query: str, *_args: object):
return []
+def test_find_linked_posts_includes_persisted_project_key_siblings(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ class ProjectConnection:
+ async def fetch(self, query: str, *_args: object):
+ if "post_lineage_edge" in query:
+ return []
+ if "select distinct person_id" in query:
+ return []
+ if "select distinct project_key" in query:
+ return [{"project_key": "project-synthetic"}]
+ if "where project_key = any" in query:
+ return [{"post_id": "post-1"}, {"post_id": "post-2"}]
+ return []
+
+ async def no_graph(_conn: object, post_ids: list[str]):
+ assert set(post_ids) == {"post-1", "post-2"}
+ return []
+
+ monkeypatch.setattr(
+ "backend.app.post_chat_ingestion.load_visible_subgraph",
+ no_graph,
+ )
+
+ linked = asyncio.run(find_linked_post_ids(ProjectConnection(), "post-1"))
+
+ assert linked.direct == frozenset()
+ assert linked.indirect == frozenset({"post-2"})
+
+
def test_gather_chat_sources_keeps_the_event_loop_responsive_during_body_normalization(
monkeypatch: pytest.MonkeyPatch,
) -> None:
From 1e467927597793739edeb9d99c3fff1258b1c9b3 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 11:33:20 +0900
Subject: [PATCH 085/393] fix(ask): preserve source publication boundary
---
backend/app/post_chat_ingestion.py | 8 ++++++--
docs/adr/0206-evidence-operations-dashboard.md | 4 +++-
tests/test_post_chat_ingestion.py | 4 +++-
3 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py
index 6d9a9daa4..082e2cf94 100644
--- a/backend/app/post_chat_ingestion.py
+++ b/backend/app/post_chat_ingestion.py
@@ -278,9 +278,13 @@ async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> Linked
project_sibling_ids: set[str] = set()
if project_keys:
project_sibling_rows = await conn.fetch(
- "select distinct post_id from post_project_mention "
- "where project_key = any($1::text[])",
+ "select distinct ppm.post_id from post_project_mention ppm "
+ "join source_post sp on sp.post_id = ppm.post_id "
+ "where ppm.project_key = any($1::text[]) "
+ f"and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='sp')} "
+ "order by ppm.post_id limit $2",
project_keys,
+ _POST_CHAT_CANDIDATE_LIMIT,
)
project_sibling_ids = {
str(row["post_id"]) for row in project_sibling_rows
diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
index 349147137..78876509a 100644
--- a/docs/adr/0206-evidence-operations-dashboard.md
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -65,7 +65,9 @@ provenance.
then bounded Event Lineage and semantic-neighborhood posts after the same
corporate-entity/process-unit ABAC check. The semantic window includes posts
carrying the same persisted `post_project_mention.project_key`; display-name
- similarity and keyword matching do not create that link. Every classification and fact
+ similarity and keyword matching do not create that link. This lookup applies
+ the shared source-post publication eligibility boundary and a deterministic
+ candidate limit before graph loading. Every classification and fact
persists its evidence post id and the SHA-256 of the exact numbered input
document. A span that does not occur in that identified document rejects
the whole provider response; linked evidence is never rewritten as focal
diff --git a/tests/test_post_chat_ingestion.py b/tests/test_post_chat_ingestion.py
index 27fbab68a..e3dc03fe3 100644
--- a/tests/test_post_chat_ingestion.py
+++ b/tests/test_post_chat_ingestion.py
@@ -16,6 +16,7 @@
normalize_chat_question,
persist_post_chat,
)
+from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from lineageweave.post_chat import (
ChatSourceDocument,
ContextualOrchestratorPostChatClient,
@@ -81,7 +82,8 @@ async def fetch(self, query: str, *_args: object):
return []
if "select distinct project_key" in query:
return [{"project_key": "project-synthetic"}]
- if "where project_key = any" in query:
+ if "where ppm.project_key = any" in query:
+ assert SOURCE_POST_ELIGIBILITY_SQL.format(alias="sp") in query
return [{"post_id": "post-1"}, {"post_id": "post-2"}]
return []
From ed777202160f9e7d18b0f5490f4512837fcea8d9 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 11:34:40 +0900
Subject: [PATCH 086/393] fix(ui): localize evidence ranking labels
---
frontend/src/App.tsx | 4 ++--
frontend/src/i18n.test.ts | 1 +
frontend/src/i18n.ts | 4 ++++
3 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 16b814666..32d502a2d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2909,7 +2909,7 @@ function analysisRunStartLabel(run: AnalysisRun): string {
return "Start temporal measurement";
}
if (run.run_kind_code === "analysis_run_topic_lineage") {
- return "Start topic lineage";
+ return "Start topic journey analysis";
}
return "Start reconstruction";
}
@@ -3200,7 +3200,7 @@ function AnalysisRunsPanel({
? selected.run_kind_code === "analysis_run_tepp"
? "Starting temporal measurement..."
: selected.run_kind_code === "analysis_run_topic_lineage"
- ? "Submitting the topic-lineage request..."
+ ? "Submitting the topic journey analysis..."
: "Reconstructing the cutoff bag..."
: analysisRunStartLabel(selected)}
diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts
index f9bcabf2f..3e1666a04 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -20,6 +20,7 @@ describe("i18n", () => {
const requiredSharedLabels = [
"Language",
"Evidence",
+ "Evidence ranking",
"Ask",
"linked",
"Post body preview",
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 638a23422..f2a363453 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -37,6 +37,7 @@ const TRANSLATIONS: Partial>> = {
"Log out": "로그아웃",
Calendar: "캘린더",
Rankings: "순위",
+ "Evidence ranking": "근거 순위",
"Evidence combined": "근거 결합 완료",
"Evidence needed": "근거 확인 필요",
"Loading rankings...": "순위를 불러오는 중...",
@@ -557,6 +558,7 @@ const TRANSLATIONS: Partial>> = {
"Log out": "退出登录",
Calendar: "日历",
Rankings: "排名",
+ "Evidence ranking": "证据排名",
"Evidence combined": "证据已合并",
"Evidence needed": "需要核对证据",
"Loading rankings...": "正在加载排名...",
@@ -1093,6 +1095,7 @@ const TRANSLATIONS: Partial>> = {
"Log out": "ログアウト",
Calendar: "カレンダー",
Rankings: "ランキング",
+ "Evidence ranking": "根拠ランキング",
"Evidence combined": "根拠の結合完了",
"Evidence needed": "根拠の確認が必要",
"Loading rankings...": "ランキングを読み込み中...",
@@ -1608,6 +1611,7 @@ const TRANSLATIONS: Partial>> = {
"Log out": "Đăng xuất",
Calendar: "Lịch",
Rankings: "Xếp hạng",
+ "Evidence ranking": "Xếp hạng bằng chứng",
"Evidence combined": "Đã kết hợp bằng chứng",
"Evidence needed": "Cần kiểm tra bằng chứng",
"Loading rankings...": "Đang tải xếp hạng...",
From c2b9d472901e2bdcee0ffab5fcf850b382c4ac0d Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 11:37:19 +0900
Subject: [PATCH 087/393] fix(seed): document immutable eligibility SQL
---
scripts/seed_demo_data.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index 4b1c46d92..a1818e6f5 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -2211,7 +2211,8 @@ def _warm_seeded_post_content(
connection = psycopg2.connect(postgres_dsn)
try:
with connection.cursor() as cur:
- cur.execute(
+ # Safe SQL: eligibility is an immutable schema fragment; no values are interpolated.
+ cur.execute( # nosemgrep: python.lang.security.audit.sqli.psycopg-sqli.psycopg-sqli, python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query
"select post_id from source_post "
"where post_title like 'Demo %post' and "
+ SOURCE_POST_ELIGIBILITY_SQL.format(alias="source_post")
From a60364a7ac1f3a0e9982c165a996804c16222204 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 11:37:38 +0900
Subject: [PATCH 088/393] style: make tuning command assembly explicit
---
scripts/plan_postgres_tuning.py | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/scripts/plan_postgres_tuning.py b/scripts/plan_postgres_tuning.py
index 61808189f..1124ccc3b 100644
--- a/scripts/plan_postgres_tuning.py
+++ b/scripts/plan_postgres_tuning.py
@@ -326,11 +326,13 @@ def _container_resources() -> tuple[int | None, int, int]:
output = _run(
[
"docker", "compose", "exec", "-T", "postgres", "sh", "-eu", "-c",
- "if [ -r /sys/fs/cgroup/memory.max ]; then cat /sys/fs/cgroup/memory.max; "
- "elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then "
- "cat /sys/fs/cgroup/memory/memory.limit_in_bytes; else printf 'max\\n'; fi; "
- "df -Pk /var/lib/postgresql/data | awk 'NR==2 {print $4}'; "
- "du -sk /var/lib/postgresql/data/pg_wal | awk '{print $1}'",
+ (
+ "if [ -r /sys/fs/cgroup/memory.max ]; then cat /sys/fs/cgroup/memory.max; "
+ + "elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then "
+ + "cat /sys/fs/cgroup/memory/memory.limit_in_bytes; else printf 'max\\n'; fi; "
+ + "df -Pk /var/lib/postgresql/data | awk 'NR==2 {print $4}'; "
+ + "du -sk /var/lib/postgresql/data/pg_wal | awk '{print $1}'"
+ ),
]
).splitlines()
if len(output) != 3:
From 444c2ef9743f1e4bdaaa582f433ca729431f40b3 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 11:43:30 +0900
Subject: [PATCH 089/393] fix(dashboard): address current review findings
---
frontend/src/App.tsx | 4 ++--
.../src/components/OperationsDashboard.test.tsx | 8 --------
frontend/src/components/OperationsDashboard.tsx | 7 +------
frontend/src/i18n.test.ts | 1 +
frontend/src/i18n.ts | 4 ++++
scripts/plan_postgres_tuning.py | 12 +++++++-----
scripts/seed_demo_data.py | 15 +++++++++++----
7 files changed, 26 insertions(+), 25 deletions(-)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 16b814666..229ca26bd 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2909,7 +2909,7 @@ function analysisRunStartLabel(run: AnalysisRun): string {
return "Start temporal measurement";
}
if (run.run_kind_code === "analysis_run_topic_lineage") {
- return "Start topic lineage";
+ return "Start topic journey analysis";
}
return "Start reconstruction";
}
@@ -3200,7 +3200,7 @@ function AnalysisRunsPanel({
? selected.run_kind_code === "analysis_run_tepp"
? "Starting temporal measurement..."
: selected.run_kind_code === "analysis_run_topic_lineage"
- ? "Submitting the topic-lineage request..."
+ ? "Starting topic journey analysis..."
: "Reconstructing the cutoff bag..."
: analysisRunStartLabel(selected)}
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index 6f6118e89..08dc61138 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -141,14 +141,6 @@ describe("OperationsDashboardView", () => {
expect(screen.queryByText(/추정 점수/)).not.toBeInTheDocument();
});
- it("shows a separate next action when topic influence does not apply", () => {
- const notApplicable = { ...data, topic_context: { ...data.topic_context, status_code: "not_applicable" as const, reason_code: "no_eligible_posts" } };
- render( undefined} />);
- expect(screen.getByText("이 기간에는 글 영향도를 계산할 대상이 없습니다.")).toBeInTheDocument();
- expect(screen.getByText("다른 기간을 선택해 분석 가능한 글이 있는지 확인하세요.")).toBeInTheDocument();
- expect(screen.queryByText("글 영향도를 아직 확인할 수 없습니다.")).not.toBeInTheDocument();
- });
-
it("opens accepted exact influence evidence and retains equal values", async () => {
const onOpenPost = vi.fn();
const influence = {
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index 6e10329ca..b9882716e 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -196,12 +196,7 @@ export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDa
글 영향도
시간 흐름별 Topic model influence
사업 가치가 아닌, 해당 글을 제외했을 때 Topic·조직 수준 모형이 변하는 정도입니다.
- {topicContext.status_code === "not_applicable" ? (
-
-
이 기간에는 글 영향도를 계산할 대상이 없습니다.
-
다른 기간을 선택해 분석 가능한 글이 있는지 확인하세요.
-
- ) : topicContext.status_code === "unavailable" ? (
+ {topicContext.status_code === "unavailable" ? (
글 영향도를 아직 확인할 수 없습니다.
분석 대상 글의 사건 시점과 조직 소속을 확인한 뒤 다시 분석하세요.
diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts
index f9bcabf2f..3e1666a04 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -20,6 +20,7 @@ describe("i18n", () => {
const requiredSharedLabels = [
"Language",
"Evidence",
+ "Evidence ranking",
"Ask",
"linked",
"Post body preview",
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 638a23422..9f1cfb81e 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -258,6 +258,7 @@ const TRANSLATIONS: Partial
>> = {
"Loading 5W1H...": "5W1H를 불러오는 중...",
"No grounded evidence for this dimension.": "이 항목을 뒷받침하는 근거가 없습니다.",
Evidence: "근거",
+ "Evidence ranking": "근거 순위",
"Post quality (IRT)": "게시글 품질 (IRT)",
Counterparties: "관련 주체",
"Issue tickets": "이슈 티켓",
@@ -774,6 +775,7 @@ const TRANSLATIONS: Partial>> = {
"Loading 5W1H...": "正在加载 5W1H…",
"No grounded evidence for this dimension.": "此维度没有有依据的证据。",
Evidence: "证据",
+ "Evidence ranking": "证据排名",
"Post quality (IRT)": "文章质量(IRT)",
Counterparties: "相关方",
"Issue tickets": "问题工单",
@@ -1301,6 +1303,7 @@ const TRANSLATIONS: Partial>> = {
"Last saved summary shown. Retry semantic refresh.": "保存済みの最新の要約を表示しています。意味更新を再試行してください。",
"Retry summary refresh": "要約の更新を再試行",
Evidence: "証拠",
+ "Evidence ranking": "根拠ランキング",
"Post quality (IRT)": "投稿品質(IRT)",
Counterparties: "関係者",
"Issue tickets": "課題チケット",
@@ -1816,6 +1819,7 @@ const TRANSLATIONS: Partial>> = {
"Last saved summary shown. Retry semantic refresh.": "Đang hiển thị bản tóm tắt đã lưu gần nhất. Hãy thử lại việc làm mới ngữ nghĩa.",
"Retry summary refresh": "Thử lại việc làm mới bản tóm tắt",
Evidence: "Bằng chứng",
+ "Evidence ranking": "Xếp hạng bằng chứng",
"Post quality (IRT)": "Chất lượng bài viết (IRT)",
Counterparties: "Các bên liên quan",
"Issue tickets": "Phiếu vấn đề",
diff --git a/scripts/plan_postgres_tuning.py b/scripts/plan_postgres_tuning.py
index 61808189f..1124ccc3b 100644
--- a/scripts/plan_postgres_tuning.py
+++ b/scripts/plan_postgres_tuning.py
@@ -326,11 +326,13 @@ def _container_resources() -> tuple[int | None, int, int]:
output = _run(
[
"docker", "compose", "exec", "-T", "postgres", "sh", "-eu", "-c",
- "if [ -r /sys/fs/cgroup/memory.max ]; then cat /sys/fs/cgroup/memory.max; "
- "elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then "
- "cat /sys/fs/cgroup/memory/memory.limit_in_bytes; else printf 'max\\n'; fi; "
- "df -Pk /var/lib/postgresql/data | awk 'NR==2 {print $4}'; "
- "du -sk /var/lib/postgresql/data/pg_wal | awk '{print $1}'",
+ (
+ "if [ -r /sys/fs/cgroup/memory.max ]; then cat /sys/fs/cgroup/memory.max; "
+ + "elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then "
+ + "cat /sys/fs/cgroup/memory/memory.limit_in_bytes; else printf 'max\\n'; fi; "
+ + "df -Pk /var/lib/postgresql/data | awk 'NR==2 {print $4}'; "
+ + "du -sk /var/lib/postgresql/data/pg_wal | awk '{print $1}'"
+ ),
]
).splitlines()
if len(output) != 3:
diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index 4b1c46d92..d4e24fcc5 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -30,6 +30,7 @@
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import psycopg2
+from psycopg2 import sql
from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from lineageweave.http_client import get_json, get_json_list, post_form
@@ -2211,10 +2212,16 @@ def _warm_seeded_post_content(
connection = psycopg2.connect(postgres_dsn)
try:
with connection.cursor() as cur:
- cur.execute(
- "select post_id from source_post "
- "where post_title like 'Demo %post' and "
- + SOURCE_POST_ELIGIBILITY_SQL.format(alias="source_post")
+ eligibility = sql.SQL(
+ SOURCE_POST_ELIGIBILITY_SQL.format(alias="source_post")
+ )
+ # psycopg2.sql composes only the immutable eligibility policy;
+ # the runtime title pattern remains a bound value below.
+ cur.execute( # nosemgrep: python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query
+ sql.SQL(
+ "select post_id from source_post where post_title like %s and {}"
+ ).format(eligibility),
+ ("Demo %post",),
)
post_ids = [str(row[0]) for row in cur.fetchall()]
finally:
From 9a8c085c7688f2a0eee9be51b83902ea825b84b2 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 11:46:17 +0900
Subject: [PATCH 090/393] fix(ask): keep project evidence out of event lineage
---
backend/app/post_chat_ingestion.py | 49 ++++++++++++++++--------------
tests/test_post_chat_ingestion.py | 26 ++++------------
2 files changed, 33 insertions(+), 42 deletions(-)
diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py
index 082e2cf94..51fcd5257 100644
--- a/backend/app/post_chat_ingestion.py
+++ b/backend/app/post_chat_ingestion.py
@@ -270,27 +270,6 @@ async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> Linked
)
sibling_post_ids = list({str(row["post_id"]) for row in sibling_rows} | {post_id})
- project_rows = await conn.fetch(
- "select distinct project_key from post_project_mention where post_id = $1",
- post_id,
- )
- project_keys = [str(row["project_key"]) for row in project_rows]
- project_sibling_ids: set[str] = set()
- if project_keys:
- project_sibling_rows = await conn.fetch(
- "select distinct ppm.post_id from post_project_mention ppm "
- "join source_post sp on sp.post_id = ppm.post_id "
- "where ppm.project_key = any($1::text[]) "
- f"and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='sp')} "
- "order by ppm.post_id limit $2",
- project_keys,
- _POST_CHAT_CANDIDATE_LIMIT,
- )
- project_sibling_ids = {
- str(row["post_id"]) for row in project_sibling_rows
- } - {post_id}
- sibling_post_ids = list(set(sibling_post_ids) | project_sibling_ids)
-
edges = await load_visible_subgraph(conn, sibling_post_ids)
start = node_key(NODE_POST, post_id)
scores = random_walk_with_restart(adjacency_from_edges(edges), start_node=start)
@@ -299,11 +278,35 @@ async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> Linked
node_id
for key, _ in related
if (node_id := parse_node_key(key)[1]) and parse_node_key(key)[0] == NODE_POST
- ).union(project_sibling_ids) - {post_id}
+ ) - {post_id}
return LinkedPostIds(direct=direct_ids - {post_id}, indirect=indirect_ids - direct_ids)
+async def find_project_sibling_post_ids(
+ conn: asyncpg.Connection, post_id: str
+) -> frozenset[str]:
+ """Published posts sharing a persisted project key, for Ask context only."""
+ project_rows = await conn.fetch(
+ "select distinct project_key from post_project_mention where post_id = $1",
+ post_id,
+ )
+ project_keys = [str(row["project_key"]) for row in project_rows]
+ if not project_keys:
+ return frozenset()
+ rows = await conn.fetch(
+ "select distinct ppm.post_id from post_project_mention ppm "
+ "join source_post sp on sp.post_id = ppm.post_id "
+ "where ppm.project_key = any($1::text[]) and ppm.post_id <> $2 "
+ f"and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='sp')} "
+ "order by ppm.post_id limit $3",
+ project_keys,
+ post_id,
+ _POST_CHAT_CANDIDATE_LIMIT,
+ )
+ return frozenset(str(row["post_id"]) for row in rows)
+
+
async def gather_chat_sources(
conn: asyncpg.Connection,
post_id: str,
@@ -353,9 +356,11 @@ async def gather_chat_sources(
]
linked = await find_linked_post_ids(conn, post_id)
+ project_sibling_ids = await find_project_sibling_post_ids(conn, post_id)
candidate_ids = [
*sorted(linked.direct),
*sorted(linked.indirect),
+ *sorted(project_sibling_ids - linked.direct - linked.indirect),
][:_POST_CHAT_CANDIDATE_LIMIT]
if not candidate_ids:
return sources
diff --git a/tests/test_post_chat_ingestion.py b/tests/test_post_chat_ingestion.py
index e3dc03fe3..c035ac52e 100644
--- a/tests/test_post_chat_ingestion.py
+++ b/tests/test_post_chat_ingestion.py
@@ -12,6 +12,7 @@
fetch_persisted_chat,
fetch_persisted_chats,
find_linked_post_ids,
+ find_project_sibling_post_ids,
gather_chat_sources,
normalize_chat_question,
persist_post_chat,
@@ -71,35 +72,20 @@ async def fetch(self, _query: str, *_args: object):
return []
-def test_find_linked_posts_includes_persisted_project_key_siblings(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
+def test_project_siblings_are_separate_from_event_lineage() -> None:
class ProjectConnection:
async def fetch(self, query: str, *_args: object):
- if "post_lineage_edge" in query:
- return []
- if "select distinct person_id" in query:
- return []
if "select distinct project_key" in query:
return [{"project_key": "project-synthetic"}]
if "where ppm.project_key = any" in query:
assert SOURCE_POST_ELIGIBILITY_SQL.format(alias="sp") in query
- return [{"post_id": "post-1"}, {"post_id": "post-2"}]
+ assert _args[1] == "post-1"
+ return [{"post_id": "post-2"}]
return []
- async def no_graph(_conn: object, post_ids: list[str]):
- assert set(post_ids) == {"post-1", "post-2"}
- return []
-
- monkeypatch.setattr(
- "backend.app.post_chat_ingestion.load_visible_subgraph",
- no_graph,
- )
-
- linked = asyncio.run(find_linked_post_ids(ProjectConnection(), "post-1"))
+ siblings = asyncio.run(find_project_sibling_post_ids(ProjectConnection(), "post-1"))
- assert linked.direct == frozenset()
- assert linked.indirect == frozenset({"post-2"})
+ assert siblings == frozenset({"post-2"})
def test_gather_chat_sources_keeps_the_event_loop_responsive_during_body_normalization(
From c8e217ca2c25f43e6c8e07bb771877965f7fc346 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 11:48:02 +0900
Subject: [PATCH 091/393] test(ask): lock graph and project boundaries
---
tests/test_post_chat_ingestion.py | 23 +++++++++++++++++++++--
1 file changed, 21 insertions(+), 2 deletions(-)
diff --git a/tests/test_post_chat_ingestion.py b/tests/test_post_chat_ingestion.py
index c035ac52e..fff8f8e8a 100644
--- a/tests/test_post_chat_ingestion.py
+++ b/tests/test_post_chat_ingestion.py
@@ -72,10 +72,17 @@ async def fetch(self, _query: str, *_args: object):
return []
-def test_project_siblings_are_separate_from_event_lineage() -> None:
+def test_project_siblings_are_separate_from_event_lineage(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
class ProjectConnection:
+ project_queries = 0
+
async def fetch(self, query: str, *_args: object):
+ if "post_lineage_edge" in query or "select distinct person_id" in query:
+ return []
if "select distinct project_key" in query:
+ self.project_queries += 1
return [{"project_key": "project-synthetic"}]
if "where ppm.project_key = any" in query:
assert SOURCE_POST_ELIGIBILITY_SQL.format(alias="sp") in query
@@ -83,9 +90,21 @@ async def fetch(self, query: str, *_args: object):
return [{"post_id": "post-2"}]
return []
- siblings = asyncio.run(find_project_sibling_post_ids(ProjectConnection(), "post-1"))
+ async def no_graph(_conn: object, post_ids: list[str]):
+ assert post_ids == ["post-1"]
+ return []
+
+ monkeypatch.setattr(
+ "backend.app.post_chat_ingestion.load_visible_subgraph",
+ no_graph,
+ )
+ connection = ProjectConnection()
+ linked = asyncio.run(find_linked_post_ids(connection, "post-1"))
+ siblings = asyncio.run(find_project_sibling_post_ids(connection, "post-1"))
+ assert linked == LinkedPostIds(direct=frozenset(), indirect=frozenset())
assert siblings == frozenset({"post-2"})
+ assert connection.project_queries == 1
def test_gather_chat_sources_keeps_the_event_loop_responsive_during_body_normalization(
From 5144535f9c18ca90b42baa411382aa318c706973 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 11:53:51 +0900
Subject: [PATCH 092/393] fix(ask): preserve project evidence in dense windows
---
backend/app/post_chat_ingestion.py | 10 ++++---
tests/test_post_chat_ingestion.py | 42 ++++++++++++++++++++++++++++++
2 files changed, 48 insertions(+), 4 deletions(-)
diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py
index 51fcd5257..018ce4abf 100644
--- a/backend/app/post_chat_ingestion.py
+++ b/backend/app/post_chat_ingestion.py
@@ -315,9 +315,11 @@ async def gather_chat_sources(
) -> list[ChatSourceDocument]:
"""Post `post_id` plus a bounded, deterministic linked-source window.
- Direct Event Lineage neighbors precede indirect Knowledge Graph
- neighbors; both groups are identifier-sorted before ABAC filtering. The
- current post plus at most seven visible linked posts become the numbered
+ Persisted semantic-project siblings precede direct Event Lineage and
+ indirect Knowledge Graph neighbors; each group is identifier-sorted before
+ ABAC filtering. This gives exact project membership a bounded opportunity
+ to supply the missing original even when graph neighborhoods are dense.
+ The current post plus at most seven visible linked posts become the numbered
source set that `post_chat` citations refer back to. Every source's body
is normalized (HTML tags/base64 images never reach the reason-and-cite
LLM call raw) before becoming a `ChatSourceDocument` -- see
@@ -358,9 +360,9 @@ async def gather_chat_sources(
linked = await find_linked_post_ids(conn, post_id)
project_sibling_ids = await find_project_sibling_post_ids(conn, post_id)
candidate_ids = [
+ *sorted(project_sibling_ids - linked.direct - linked.indirect),
*sorted(linked.direct),
*sorted(linked.indirect),
- *sorted(project_sibling_ids - linked.direct - linked.indirect),
][:_POST_CHAT_CANDIDATE_LIMIT]
if not candidate_ids:
return sources
diff --git a/tests/test_post_chat_ingestion.py b/tests/test_post_chat_ingestion.py
index fff8f8e8a..58cf4639f 100644
--- a/tests/test_post_chat_ingestion.py
+++ b/tests/test_post_chat_ingestion.py
@@ -7,6 +7,7 @@
import pytest
from backend.app.post_chat_ingestion import (
+ _POST_CHAT_CANDIDATE_LIMIT,
LinkedPostIds,
cited_post_images,
fetch_persisted_chat,
@@ -107,6 +108,47 @@ async def no_graph(_conn: object, post_ids: list[str]):
assert connection.project_queries == 1
+def test_project_sibling_precedes_a_dense_graph_candidate_window(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Exact project evidence is not crowded out by a dense graph window."""
+
+ root_id = "00000000-0000-0000-0000-000000000001"
+ project_id = "00000000-0000-0000-0000-000000000002"
+ direct_ids = {
+ f"00000000-0000-0000-0001-{index:012d}" for index in range(40)
+ }
+
+ class DenseConnection(_SourceConnection):
+ candidate_ids: list[str] = []
+
+ async def fetch(self, query: str, *args: object):
+ if "select post_id, post_title, post_body, visibility_code" in query:
+ self.candidate_ids = list(args[0])
+ return []
+ return []
+
+ async def dense_links(_conn: object, _post_id: str) -> LinkedPostIds:
+ return LinkedPostIds(frozenset(direct_ids), frozenset())
+
+ async def project_link(_conn: object, _post_id: str) -> frozenset[str]:
+ return frozenset({project_id})
+
+ monkeypatch.setattr(
+ "backend.app.post_chat_ingestion.find_linked_post_ids", dense_links
+ )
+ monkeypatch.setattr(
+ "backend.app.post_chat_ingestion.find_project_sibling_post_ids",
+ project_link,
+ )
+ connection = DenseConnection()
+
+ asyncio.run(gather_chat_sources(connection, root_id, lambda _row: True))
+
+ assert connection.candidate_ids[0] == project_id
+ assert len(connection.candidate_ids) == _POST_CHAT_CANDIDATE_LIMIT
+
+
def test_gather_chat_sources_keeps_the_event_loop_responsive_during_body_normalization(
monkeypatch: pytest.MonkeyPatch,
) -> None:
From 63372cc9cb138c02be823a7e50625703d59edf52 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 11:55:29 +0900
Subject: [PATCH 093/393] fix(orchestrator): register remote embedding agent
---
docker/contextual-orchestrator/start.py | 15 ++++++++++-
tests/test_contextual_orchestrator_start.py | 28 ++++++++++++++++++---
2 files changed, 39 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 01dc5d189..be0d93db2 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -48,6 +48,7 @@ def main() -> None:
raise SystemExit("LLM_GATEWAY_API_URL or LLM_GATEWAY_URL is required to start the gateway")
if not provider_url.rstrip("/").endswith("/v1"):
provider_url = provider_url.rstrip("/") + "/v1"
+ embedding_model = os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", "").strip()
raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
try:
max_output_tokens = int(raw_limit)
@@ -68,7 +69,18 @@ def main() -> None:
agent["base_url"] = provider_url
agent["credential_key"] = "LLM_GATEWAY_API_KEY"
agent.setdefault("provider_protocol", "auto")
- os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", None)
+ if embedding_model:
+ agents["agents"].append(
+ {
+ "id": "gateway_embedding_agent",
+ "model": embedding_model,
+ "provider_protocol": "auto",
+ "base_url": provider_url,
+ "credential_key": "LLM_GATEWAY_API_KEY",
+ "tags": ["embedding"],
+ "priority": 1,
+ }
+ )
agents_path.write_text(json.dumps(agents), encoding="utf-8")
from contextual_orchestrator.credentials import register_credential
@@ -98,6 +110,7 @@ def main() -> None:
str(max_body_bytes),
]
del provider_url
+ del embedding_model
del auth_token
from contextual_orchestrator.__main__ import main as serve
diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py
index ad65a4c95..4830dc4c6 100644
--- a/tests/test_contextual_orchestrator_start.py
+++ b/tests/test_contextual_orchestrator_start.py
@@ -65,7 +65,10 @@ def test_provider_key_is_not_aliased_as_gateway_transport(monkeypatch) -> None:
module.main()
-def test_bootstrap_leaves_embedding_selection_to_the_orchestrator(monkeypatch) -> None:
+@pytest.mark.parametrize("embedding_model", ["embedding-model", ""])
+def test_bootstrap_registers_configured_remote_embedding_agent(
+ monkeypatch, embedding_model: str
+) -> None:
module = _load_start_module()
captured: dict[str, object] = {}
@@ -111,7 +114,10 @@ def serve() -> None:
monkeypatch.setenv("BYTEZ_API_KEY", "bytez-key")
monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_TOKEN", "orchestrator-token")
monkeypatch.setenv("LLM_GATEWAY_API_URL", "https://gateway.example")
- monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_MODEL", "embedding-model")
+ if embedding_model:
+ monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_MODEL", embedding_model)
+ else:
+ monkeypatch.delenv("LLM_GATEWAY_EMBEDDING_MODEL", raising=False)
module.main()
@@ -138,5 +144,21 @@ def serve() -> None:
} & os.environ.keys()
agents = captured["agents"]
assert isinstance(agents, dict)
- assert not [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])]
+ embedding_agents = [
+ agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])
+ ]
+ if embedding_model:
+ assert embedding_agents == [
+ {
+ "id": "gateway_embedding_agent",
+ "model": embedding_model,
+ "provider_protocol": "auto",
+ "base_url": "https://gateway.example/v1",
+ "credential_key": "LLM_GATEWAY_API_KEY",
+ "tags": ["embedding"],
+ "priority": 1,
+ }
+ ]
+ else:
+ assert embedding_agents == []
assert "LLM_GATEWAY_EMBEDDING_MODEL" not in os.environ
From 88c22133cce3d6a21ccbdb292106492c2d15ef11 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 11:59:37 +0900
Subject: [PATCH 094/393] build(orchestrator): pin provider embedding runtime
---
docker/contextual-orchestrator/Dockerfile | 2 +-
docs/adr/0083-orchestrator-runtime-commit-pin.md | 4 +++-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 0af60f58c..2e3b5d4e5 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/aacef77deb378dde1f0c69c9947ff8c0fd0a1a30.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 1ad14cade..0876a94ab 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89`. The pin remains explicit
+commit `aacef77deb378dde1f0c69c9947ff8c0fd0a1a30`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
@@ -37,6 +37,8 @@ The runtime contract is:
endpoint; embedding-only rows are not added to the chat agent pool.
- A batch embedding request may omit `model`; contextual-orchestrator selects
an embedding-capable model and returns its identity for subsequent batches.
+- An explicit remote agent tagged `embedding` uses its provider-backed
+ embedding transport rather than a local placeholder implementation.
- `json_object`, `json_schema`, and Responses JSON formats run conduct plus
synthesis. Tool requests never silently fall back to one agent.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index b287d3470..8db27b587 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89"
+ expected_embedding_contract_commit = "aacef77deb378dde1f0c69c9947ff8c0fd0a1a30"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From ef7aebdd894e522519c25225af35b27ba66b1492 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 12:00:37 +0900
Subject: [PATCH 095/393] docs(orchestrator): separate embedding bootstrap
boundary
---
.../0030-external-llm-gateway-environment.md | 17 +++++++++++------
tests/test_documentation_hygiene.py | 10 ++++++++++
2 files changed, 21 insertions(+), 6 deletions(-)
diff --git a/docs/adr/0030-external-llm-gateway-environment.md b/docs/adr/0030-external-llm-gateway-environment.md
index fccc1636f..58910df4b 100644
--- a/docs/adr/0030-external-llm-gateway-environment.md
+++ b/docs/adr/0030-external-llm-gateway-environment.md
@@ -75,12 +75,17 @@ must never be returned through a buyer-facing API or persisted failure detail.
When they are blank, contextual-orchestrator resolves the registered agent
model, so a local or provider-specific model name cannot leak into this
application or be assumed available on an external gateway.
-- LineageWeave does not configure an embedding model. Its first batch request
- omits `model`; contextual-orchestrator selects a provider-neutral embedding
- model and returns that identity on submission and polling responses.
- LineageWeave binds that identity for later batches and persists it with every
- vector. A missing or changed identity, or an incomplete vector batch, fails
- closed and cannot make post content complete.
+- LineageWeave embedding requests do not select a model: every batch omits
+ `model`. At the Compose process boundary an operator-supplied
+ `LLM_GATEWAY_EMBEDDING_MODEL` may register one explicit remote agent tagged
+ `embedding` in contextual-orchestrator, using the same provider URL and
+ credential handle as the gateway. The bootstrap removes that environment
+ value before serving; application code never reads it, sends it in a request,
+ or calls the provider directly. contextual-orchestrator returns the selected
+ identity on submission and polling responses. LineageWeave binds that
+ identity for later batches and persists it with every vector. A missing or
+ changed identity, or an incomplete vector batch, fails closed and cannot make
+ post content complete.
- `LLM_API_KEY`, `LLM_API_GATEWAY`, and `LLM_GATEWAY_URL` are compatibility
aliases only; `LLM_GATEWAY_API_KEY` and `LLM_GATEWAY_API_URL` are the
canonical names for
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index 8db27b587..864827418 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -131,3 +131,13 @@ def test_orchestrator_runtime_pin_matches_adr() -> None:
assert adr_match is not None
assert docker_match.group(1) == adr_match.group(1)
assert docker_match.group(1) == expected_embedding_contract_commit
+
+
+def test_embedding_bootstrap_contract_keeps_request_model_free() -> None:
+ """ADR distinguishes remote-agent registration from request selection."""
+ adr = (_ADR_DIRECTORY / "0030-external-llm-gateway-environment.md").read_text(
+ encoding="utf-8"
+ )
+ assert "LineageWeave embedding requests do not select a model" in adr
+ assert "LLM_GATEWAY_EMBEDDING_MODEL" in adr
+ assert "application code never reads it" in adr
From 877533174abac2fe2ade49f5cede4e6240809e3d Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 12:10:34 +0900
Subject: [PATCH 096/393] fix(ui): keep missing-evidence guidance accurate
---
frontend/src/components/OperationsDashboard.stories.tsx | 4 ++--
frontend/src/components/OperationsDashboard.test.tsx | 2 +-
frontend/src/components/OperationsDashboard.tsx | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx
index affe0f78e..9aa747084 100644
--- a/frontend/src/components/OperationsDashboard.stories.tsx
+++ b/frontend/src/components/OperationsDashboard.stories.tsx
@@ -99,7 +99,7 @@ export const TopicInfluenceAccepted: Story = {
await expect(canvas.getByRole("heading", { name: "시간 흐름별 Topic model influence" })).toBeVisible();
await expect(canvas.getByText(/휴면 \/ 재활성/)).toBeVisible();
await expect(canvas.getAllByText("4.25")).toHaveLength(3);
- await expect(canvas.getByText(/순번이나 임의 가중치를 추가하지 않습니다/)).toBeVisible();
+ await expect(canvas.getByText(/영향도와 불확실성을 함께 비교하고 같은 값은 동점으로 확인하세요/)).toBeVisible();
},
};
@@ -127,7 +127,7 @@ export const RequiredFactMissing: Story = {
onOpenPost: () => undefined,
},
play: async ({ canvasElement }) => {
- await expect(within(canvasElement).getByText(/수주 Pool: 권한 범위 내 근거가 없습니다/)).toBeVisible();
+ await expect(within(canvasElement).getByText(/수주 Pool: 관련 원문을 추가한 뒤 다시 분석하세요/)).toBeVisible();
},
};
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index 069883228..8bc1f10a1 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -55,7 +55,7 @@ describe("OperationsDashboardView", () => {
expect(screen.getByText("3 Event · 2글")).toBeInTheDocument();
expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument();
expect(screen.getByText("원인 수주")).toBeInTheDocument();
- expect(screen.getByText(/수주 Pool: 관련 원문을 찾고 다시 분석하고 있습니다/)).toBeInTheDocument();
+ expect(screen.getByText(/수주 Pool: 관련 원문을 추가한 뒤 다시 분석하세요/)).toBeInTheDocument();
expect(screen.getByText("2일 3시간 30분 0초")).toBeInTheDocument();
expect(screen.getByText(/진행 중 1건 · 종료 확인 0건/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" }));
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index ad03edd0d..64d60289a 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -172,7 +172,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.missing_facts.length ? (
추가 확인 필요
- {item.missing_facts.map((fact) => {fact.fact_type_label}: 관련 원문을 찾고 다시 분석하고 있습니다. )}
+ {item.missing_facts.map((fact) => {fact.fact_type_label}: 관련 원문을 추가한 뒤 다시 분석하세요. )}
) : null}
onOpenPost(item.evidence_post_id)}>분류 근거 글 열기
From a18eb200da01ededa7f225985b06810f1331e5c7 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 12:18:12 +0900
Subject: [PATCH 097/393] fix(ui): explain automatic evidence retry
---
frontend/src/components/OperationsDashboard.stories.tsx | 2 +-
frontend/src/components/OperationsDashboard.test.tsx | 2 +-
frontend/src/components/OperationsDashboard.tsx | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx
index 9aa747084..665b716f0 100644
--- a/frontend/src/components/OperationsDashboard.stories.tsx
+++ b/frontend/src/components/OperationsDashboard.stories.tsx
@@ -127,7 +127,7 @@ export const RequiredFactMissing: Story = {
onOpenPost: () => undefined,
},
play: async ({ canvasElement }) => {
- await expect(within(canvasElement).getByText(/수주 Pool: 관련 원문을 추가한 뒤 다시 분석하세요/)).toBeVisible();
+ await expect(within(canvasElement).getByText(/수주 Pool: 관련 근거를 찾으면 자동으로 다시 분석합니다. 이후 결과를 다시 확인하세요/)).toBeVisible();
},
};
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index 8bc1f10a1..df65027b3 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -55,7 +55,7 @@ describe("OperationsDashboardView", () => {
expect(screen.getByText("3 Event · 2글")).toBeInTheDocument();
expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument();
expect(screen.getByText("원인 수주")).toBeInTheDocument();
- expect(screen.getByText(/수주 Pool: 관련 원문을 추가한 뒤 다시 분석하세요/)).toBeInTheDocument();
+ expect(screen.getByText(/수주 Pool: 관련 근거를 찾으면 자동으로 다시 분석합니다. 이후 결과를 다시 확인하세요/)).toBeInTheDocument();
expect(screen.getByText("2일 3시간 30분 0초")).toBeInTheDocument();
expect(screen.getByText(/진행 중 1건 · 종료 확인 0건/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" }));
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index 64d60289a..2b699505d 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -172,7 +172,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.missing_facts.length ? (
추가 확인 필요
- {item.missing_facts.map((fact) => {fact.fact_type_label}: 관련 원문을 추가한 뒤 다시 분석하세요. )}
+ {item.missing_facts.map((fact) => {fact.fact_type_label}: 관련 근거를 찾으면 자동으로 다시 분석합니다. 이후 결과를 다시 확인하세요. )}
) : null}
onOpenPost(item.evidence_post_id)}>분류 근거 글 열기
From 759a3de193edb118d33668fa50f0c227c18e4fd1 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 12:34:17 +0900
Subject: [PATCH 098/393] fix(operations): reanalyze missing facts on new
evidence
---
CHANGELOG.md | 5 +-
backend/app/post_content_worker.py | 56 ++++++++++++++++-
backend/tests/test_api.py | 10 +++-
docs/product-technical-gap-baseline.md | 2 +-
.../OperationsDashboard.stories.tsx | 2 +-
.../components/OperationsDashboard.test.tsx | 2 +-
.../src/components/OperationsDashboard.tsx | 2 +-
tests/test_post_content_worker.py | 60 +++++++++++++++++++
8 files changed, 129 insertions(+), 10 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 41ac253fc..ac88db98d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,8 +11,9 @@ All notable changes to this project are documented here. Format follows
- Ask Agent now searches posts with the same persisted semantic project key as
part of its authorized evidence window before re-analysis. Missing evidence
remains a retry state rather than a request for the reader to attach an
- original, and the composer now uses an accessible form with a stable action
- and separate progress status.
+ original; newly analyzed project evidence now requeues completed sibling
+ analyses that still have missing facts. The composer now uses an accessible
+ form with a stable action and separate progress status.
- Docker Compose now has one canonical `lineageweave` project containing the
complete synthetic product stack. The backend receives the existing TEPP API
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 5cf7407b0..decf9674d 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -21,12 +21,16 @@
RUNNING,
STALE_RUNNING_INTERVAL,
SUCCEEDED,
+ ensure_post_content_job,
post_content_is_complete,
republish_queued_post_content_jobs,
transition_post_content_job,
)
from backend.app.operations_case_ingestion import persist_operations_cases
-from backend.app.post_chat_ingestion import gather_chat_sources
+from backend.app.post_chat_ingestion import (
+ find_project_sibling_post_ids,
+ gather_chat_sources,
+)
from lineageweave.embedding_client import EmbeddingClient
from lineageweave.http_client import HttpClientError
from lineageweave.image_content import ImageContentClient
@@ -106,6 +110,42 @@ def can_see(row: asyncpg.Record) -> bool:
)
+async def _requeue_project_missing_case_jobs(
+ pool: asyncpg.Pool,
+ post_id: str,
+) -> int:
+ """Re-analyze older project siblings that still lack required facts."""
+ async with pool.acquire() as conn:
+ async with conn.transaction():
+ sibling_ids = await find_project_sibling_post_ids(conn, post_id)
+ if not sibling_ids:
+ return 0
+ rows = await conn.fetch(
+ """
+ select distinct post.post_id, post.post_body
+ from operations_case_missing_fact missing
+ join source_post post on post.post_id = missing.post_id
+ join post_content_ingestion_job job on job.post_id = missing.post_id
+ where missing.post_id = any($1::uuid[])
+ and job.status_code = $2
+ and nullif(btrim(post.post_body), '') is not null
+ order by post.post_id
+ """,
+ [UUID(sibling_id) for sibling_id in sibling_ids],
+ SUCCEEDED,
+ )
+ queued = 0
+ for row in rows:
+ request = await ensure_post_content_job(
+ conn,
+ str(row["post_id"]),
+ str(row["post_body"]),
+ content_complete=False,
+ )
+ queued += int(request.should_publish)
+ return queued
+
+
async def _stream_tail(client: redis.Redis) -> str:
"""Start after historical wake-ups; the normalized ledger drives recovery."""
with traced(
@@ -136,7 +176,12 @@ async def _claim_job(
j.status_code as job_status_code,
j.attempt_count as job_attempt_count,
j.started_at as job_started_at,
- j.queued_at as job_queued_at
+ j.queued_at as job_queued_at,
+ (
+ select analysis.source_body_sha256
+ from operations_case_analysis analysis
+ where analysis.post_id = p.post_id
+ ) as case_analysis_source_body_sha256
from post_content_ingestion_job j
join source_post p on p.post_id = j.post_id
where j.post_id = $1::uuid
@@ -400,6 +445,13 @@ async def process_post_content_job(
expected_attempt_count=attempt_count,
)
return
+ if (
+ settings.orchestrator_base_url
+ and settings.orchestrator_api_key
+ and row.get("case_analysis_source_body_sha256")
+ != source_body_digest
+ ):
+ await _requeue_project_missing_case_jobs(pool, post_id)
except Exception as exc: # noqa: BLE001 - durable failure is recorded for retry.
_logger.error("post content ingestion failed for post_id=%s", post_id)
outcome = (
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 08f909f28..0d10021b8 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -1068,7 +1068,10 @@ def test_create_analysis_run_records_pending_without_inventing_a_score(
},
)
assert tepp.status_code == 422
- assert "invent a measurement" in tepp.json()["detail"]
+ assert (
+ tepp.json()["detail"]
+ == "Open the failed temporal measurement, ask an administrator to restore analysis, then re-run it."
+ )
assert "theta" not in tepp.json()["detail"].lower()
report = client.post(
@@ -1227,7 +1230,10 @@ def test_start_analysis_run_recovers_the_a100_fork(
},
)
assert tepp_create.status_code == 422
- assert "invent a measurement" in tepp_create.json()["detail"]
+ assert (
+ tepp_create.json()["detail"]
+ == "Open the failed temporal measurement, ask an administrator to restore analysis, then re-run it."
+ )
admin_conn = psycopg2.connect(seeded_db["dsn"])
admin_conn.autocommit = True
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 016769cd4..d1dadac61 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -28,7 +28,7 @@ containers while preserving named volumes.
| Apple-Silicon mathematical acceleration | ADR 0226 macOS-native Rust owner service with authenticated MLX Metal execution receipts | Normative boundary accepted; TEPP, fast-mlsirm, and RankWeave owner implementations and actual Metal parity receipts remain required before activation |
| Ask answer citation-to-event navigation | ADR 0225; authorized cited source, observed source clock, focused evidence layer, and full-post navigation | Stacked candidate renders numbered answer citations and chronologically ordered evidence cards with bidirectional focus. Event and record clocks stay distinct; this list does not claim a Project Journey. Storybook and component interaction evidence are included; authenticated runtime screenshots remain required at the exact candidate head. |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
-| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback. Post-scoped evidence collection now also follows exact persisted `project_key` membership, so the system searches authorized related originals before re-analysis instead of asking the reader to attach them. Focused backend tests pass; authenticated exact-head runtime acceptance remains pending. |
+| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback. Post-scoped evidence collection follows exact persisted `project_key` membership, and newly analyzed project evidence durably requeues completed sibling analyses that still have missing facts. Focused backend tests pass; authenticated exact-head runtime acceptance remains pending. |
| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending |
| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet |
| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open at `063f10f3`; stacked #251–#254 provide fail-closed input validation, full joint precision, deterministic joint plausible-value draws, and the canonical research register, while complete provenance assembly remains gated. fast-mlsirm PR #1418 validates the Rust consumer envelope but intentionally returns `EstimatorUnavailable` until the scientific estimator lands. Runtime therefore remains honestly unavailable with no local Python or fallback score. |
diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx
index 9aa747084..034d2dfe6 100644
--- a/frontend/src/components/OperationsDashboard.stories.tsx
+++ b/frontend/src/components/OperationsDashboard.stories.tsx
@@ -127,7 +127,7 @@ export const RequiredFactMissing: Story = {
onOpenPost: () => undefined,
},
play: async ({ canvasElement }) => {
- await expect(within(canvasElement).getByText(/수주 Pool: 관련 원문을 추가한 뒤 다시 분석하세요/)).toBeVisible();
+ await expect(within(canvasElement).getByText(/수주 Pool: 관련 원문을 찾고 다시 분석하고 있습니다/)).toBeVisible();
},
};
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index 8bc1f10a1..069883228 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -55,7 +55,7 @@ describe("OperationsDashboardView", () => {
expect(screen.getByText("3 Event · 2글")).toBeInTheDocument();
expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument();
expect(screen.getByText("원인 수주")).toBeInTheDocument();
- expect(screen.getByText(/수주 Pool: 관련 원문을 추가한 뒤 다시 분석하세요/)).toBeInTheDocument();
+ expect(screen.getByText(/수주 Pool: 관련 원문을 찾고 다시 분석하고 있습니다/)).toBeInTheDocument();
expect(screen.getByText("2일 3시간 30분 0초")).toBeInTheDocument();
expect(screen.getByText(/진행 중 1건 · 종료 확인 0건/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" }));
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index 64d60289a..ad03edd0d 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -172,7 +172,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.missing_facts.length ? (
추가 확인 필요
- {item.missing_facts.map((fact) => {fact.fact_type_label}: 관련 원문을 추가한 뒤 다시 분석하세요. )}
+ {item.missing_facts.map((fact) => {fact.fact_type_label}: 관련 원문을 찾고 다시 분석하고 있습니다. )}
) : null}
onOpenPost(item.evidence_post_id)}>분류 근거 글 열기
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index 3a9d122df..39d143e60 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -41,6 +41,9 @@ def transaction(self) -> _Transaction:
async def fetchrow(self, *_args: object):
return self.row
+ async def fetch(self, *_args: object):
+ return []
+
async def fetchval(self, query: str, *_args: object):
if self.values:
return self.values.pop(0)
@@ -172,6 +175,63 @@ async def fetch(self, *_args: object):
))
+def test_new_project_evidence_requeues_siblings_with_missing_facts(monkeypatch) -> None:
+ """A newly analyzed project post wakes completed missing-fact analyses."""
+ sibling_id = "00000000-0000-0000-0000-000000000002"
+
+ class MissingFactConnection(_Connection):
+ async def fetch(self, query: str, *_args: object):
+ if "operations_case_missing_fact" in query:
+ assert _args[1] == SUCCEEDED
+ return [{"post_id": sibling_id, "post_body": "Synthetic sibling body"}]
+ return []
+
+ async def siblings(_conn, _post_id):
+ return frozenset({sibling_id})
+
+ queued: list[tuple[str, str, bool]] = []
+
+ async def ensure(_conn, post_id, body, *, content_complete):
+ queued.append((post_id, body, content_complete))
+ return SimpleNamespace(should_publish=True)
+
+ monkeypatch.setattr(post_content_worker, "find_project_sibling_post_ids", siblings)
+ monkeypatch.setattr(post_content_worker, "ensure_post_content_job", ensure)
+
+ count = asyncio.run(
+ post_content_worker._requeue_project_missing_case_jobs(
+ _Pool(MissingFactConnection()),
+ "00000000-0000-0000-0000-000000000001",
+ )
+ )
+
+ assert count == 1
+ assert queued == [(sibling_id, "Synthetic sibling body", False)]
+
+
+def test_missing_fact_requeue_stops_without_project_siblings(monkeypatch) -> None:
+ """An unlinked post does not create speculative retry work."""
+
+ async def no_siblings(_conn, _post_id):
+ return frozenset()
+
+ monkeypatch.setattr(
+ post_content_worker,
+ "find_project_sibling_post_ids",
+ no_siblings,
+ )
+
+ assert (
+ asyncio.run(
+ post_content_worker._requeue_project_missing_case_jobs(
+ _Pool(_Connection()),
+ "00000000-0000-0000-0000-000000000001",
+ )
+ )
+ == 0
+ )
+
+
def test_terminal_failed_job_ignores_a_stale_duplicate_wakeup() -> None:
connection = _Connection(_row(FAILED, POST_CONTENT_MAX_ATTEMPTS))
From 34469d12acd0d6d39ccecaaa939a0e3093aeb27c Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 12:44:11 +0900
Subject: [PATCH 099/393] fix(runtime): restore semantic backfill contracts
---
backend/app/post_content_worker.py | 4 ++--
docker/contextual-orchestrator/Dockerfile | 3 ++-
docker/contextual-orchestrator/start.py | 1 -
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 5cf7407b0..60c1c07a7 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -172,7 +172,7 @@ async def _claim_job(
return None
if status_code == QUEUED and attempt_count > 0:
retry_ready = await conn.fetchval(
- "select now() >= $1 + $2::interval",
+ "select now() >= $1::timestamptz + $2::interval",
row["job_queued_at"],
POST_CONTENT_RETRY_INTERVAL,
)
@@ -197,7 +197,7 @@ async def _claim_job(
return None
if status_code == RUNNING and row["job_started_at"] is not None:
stale = await conn.fetchval(
- "select now() - $1 > $2::interval",
+ "select now() - $1::timestamptz > $2::interval",
row["job_started_at"],
STALE_RUNNING_INTERVAL,
)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 2e3b5d4e5..d1cb4b45c 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,13 +5,14 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/aacef77deb378dde1f0c69c9947ff8c0fd0a1a30.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/052fdd841e912fbed3f646436f02d6b112161e18.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
&& cp -R /tmp/contextual-orchestrator/examples /app/examples \
&& rm -rf /tmp/contextual-orchestrator /tmp/contextual-orchestrator.tar.gz \
&& python -m pip install --no-cache-dir \
+ 'cryptography>=43.0' \
'opentelemetry-api>=1.30.0' \
'opentelemetry-sdk>=1.30.0' \
'opentelemetry-exporter-otlp-proto-http>=1.30.0' \
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index be0d93db2..07fa48cc1 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -96,7 +96,6 @@ def main() -> None:
"--agents",
str(agents_path),
"--auto-discover-model-agents",
- "--allow-discovery-failures",
"--host",
"0.0.0.0",
"--port",
From dd65e92e53d80eb12ac7fd076d41d5ecc9a3ac6f Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 12:47:35 +0900
Subject: [PATCH 100/393] fix(ask): keep project evidence first
---
backend/app/post_chat_ingestion.py | 6 +++---
tests/test_post_chat_ingestion.py | 3 ++-
2 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py
index 018ce4abf..5f72942ad 100644
--- a/backend/app/post_chat_ingestion.py
+++ b/backend/app/post_chat_ingestion.py
@@ -360,9 +360,9 @@ async def gather_chat_sources(
linked = await find_linked_post_ids(conn, post_id)
project_sibling_ids = await find_project_sibling_post_ids(conn, post_id)
candidate_ids = [
- *sorted(project_sibling_ids - linked.direct - linked.indirect),
- *sorted(linked.direct),
- *sorted(linked.indirect),
+ *sorted(project_sibling_ids),
+ *sorted(linked.direct - project_sibling_ids),
+ *sorted(linked.indirect - project_sibling_ids),
][:_POST_CHAT_CANDIDATE_LIMIT]
if not candidate_ids:
return sources
diff --git a/tests/test_post_chat_ingestion.py b/tests/test_post_chat_ingestion.py
index 58cf4639f..71cbd755b 100644
--- a/tests/test_post_chat_ingestion.py
+++ b/tests/test_post_chat_ingestion.py
@@ -114,10 +114,11 @@ def test_project_sibling_precedes_a_dense_graph_candidate_window(
"""Exact project evidence is not crowded out by a dense graph window."""
root_id = "00000000-0000-0000-0000-000000000001"
- project_id = "00000000-0000-0000-0000-000000000002"
+ project_id = "00000000-0000-0000-9999-999999999999"
direct_ids = {
f"00000000-0000-0000-0001-{index:012d}" for index in range(40)
}
+ direct_ids.add(project_id)
class DenseConnection(_SourceConnection):
candidate_ids: list[str] = []
From 8333468304264eedf8d70aa2ffa972c92e5dd200 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 12:52:56 +0900
Subject: [PATCH 101/393] fix(worker): persist operational cases before
optional content enrichment
---
backend/app/post_content_worker.py | 96 ++++++++++++++++++++----------
tests/test_post_content_worker.py | 92 +++++++++++++++++++++++++++-
2 files changed, 155 insertions(+), 33 deletions(-)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 5cf7407b0..cc66334b6 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -106,6 +106,58 @@ def can_see(row: asyncpg.Record) -> bool:
)
+async def _persist_operations_case_analysis_if_needed(
+ pool: asyncpg.Pool,
+ post_id: str,
+ source_body_digest: str,
+ raw_body: str,
+ row: asyncpg.Record,
+ vision_client: ImageContentClient,
+ session_id: str,
+ orchestrator_base_url: str,
+ orchestrator_api_key: str,
+) -> None:
+ """Persist evidence-bound cases once per exact source-body version."""
+ async with pool.acquire() as conn:
+ already_persisted = bool(
+ await conn.fetchval(
+ "select exists (select 1 from operations_case_analysis "
+ "where post_id = $1 and source_body_sha256 = $2)",
+ post_id,
+ source_body_digest,
+ )
+ )
+ if already_persisted:
+ return
+ case_client = ContextualOrchestratorOperationsCaseAnalysisClient(
+ orchestrator_base_url,
+ orchestrator_api_key,
+ )
+ context = " | ".join(
+ f"{name}={row[name]}"
+ for name in (
+ "source_project_code",
+ "source_project_name",
+ "source_sales_pool_code",
+ "source_sales_pool_name",
+ "voc_type_code",
+ )
+ if row.get(name) is not None and str(row[name]).strip()
+ )
+ evidence_sources = await _operations_evidence_sources(
+ pool, post_id, row, vision_client
+ )
+ cases = await asyncio.to_thread(case_client.analyze, evidence_sources, context)
+ async with pool.acquire() as conn:
+ await persist_operations_cases(
+ conn,
+ post_id,
+ raw_body,
+ session_id,
+ cases,
+ )
+
+
async def _stream_tail(client: redis.Redis) -> str:
"""Start after historical wake-ups; the normalized ledger drives recovery."""
with traced(
@@ -335,6 +387,18 @@ async def process_post_content_job(
structure_client = structure_factory()
with use_llm_metadata(metadata):
vision_client = vision_factory()
+ if settings.orchestrator_base_url and settings.orchestrator_api_key:
+ await _persist_operations_case_analysis_if_needed(
+ pool,
+ post_id,
+ source_body_digest,
+ raw_body,
+ row,
+ vision_client,
+ metadata["lineageweave_post_session_id"],
+ settings.orchestrator_base_url,
+ settings.orchestrator_api_key,
+ )
normalized = await asyncio.to_thread(
normalize_post_body, raw_body, vision_client
)
@@ -349,38 +413,6 @@ async def process_post_content_job(
structure_client=structure_client,
post_title=str(row["post_title"]),
)
- if settings.orchestrator_base_url and settings.orchestrator_api_key:
- case_client = ContextualOrchestratorOperationsCaseAnalysisClient(
- settings.orchestrator_base_url,
- settings.orchestrator_api_key,
- )
- context = " | ".join(
- f"{name}={row[name]}"
- for name in (
- "source_project_code",
- "source_project_name",
- "source_sales_pool_code",
- "source_sales_pool_name",
- "voc_type_code",
- )
- if row.get(name) is not None and str(row[name]).strip()
- )
- evidence_sources = await _operations_evidence_sources(
- pool, post_id, row, vision_client
- )
- cases = await asyncio.to_thread(
- case_client.analyze,
- evidence_sources,
- context,
- )
- async with pool.acquire() as conn:
- await persist_operations_cases(
- conn,
- post_id,
- raw_body,
- metadata["lineageweave_post_session_id"],
- cases,
- )
async with pool.acquire() as conn:
complete = await post_content_is_complete(
conn,
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index 3a9d122df..9aae0da51 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -245,7 +245,7 @@ async def incomplete(*_args, **_kwargs) -> bool:
def test_incomplete_provider_output_is_requeued_with_a_failure_code(monkeypatch) -> None:
- connection = _Connection(values=[2])
+ connection = _Connection(values=[False, 2])
pool = _Pool(connection)
async def claim(*_args, **_kwargs):
@@ -304,6 +304,96 @@ async def evidence_sources(*_args, **_kwargs):
assert analyzed_bodies == ["A synthetic post body with a retrieval unit."]
+def test_existing_case_analysis_skips_duplicate_orchestrator_call(monkeypatch) -> None:
+ """A retry preserves same-digest analysis instead of spending another call."""
+ connection = _Connection(values=[True])
+ called: list[str] = []
+ monkeypatch.setattr(
+ post_content_worker,
+ "ContextualOrchestratorOperationsCaseAnalysisClient",
+ lambda *_args: called.append("client") or SimpleNamespace(),
+ )
+
+ asyncio.run(
+ post_content_worker._persist_operations_case_analysis_if_needed(
+ _Pool(connection),
+ "00000000-0000-0000-0000-000000000001",
+ "a" * 64,
+ "Synthetic source body",
+ _row(RUNNING, 1),
+ SimpleNamespace(available=True),
+ "synthetic-session",
+ "gateway",
+ "key",
+ )
+ )
+
+ assert called == []
+
+
+def test_case_analysis_persists_before_content_provider_failure(monkeypatch) -> None:
+ """Independent case evidence survives a later structure or embedding outage."""
+ connection = _Connection(values=[False, 2])
+ pool = _Pool(connection)
+ persisted: list[str] = []
+
+ async def claim(*_args, **_kwargs):
+ return _row(RUNNING, 1)
+
+ async def fail_content(*_args, **_kwargs):
+ raise TimeoutError("synthetic provider timeout")
+
+ async def evidence_sources(*_args, **_kwargs):
+ return (
+ OperationsEvidenceSource(
+ "post-1", "Synthetic", "A synthetic source body."
+ ),
+ )
+
+ async def persist_cases(_conn, _post_id, *_args):
+ persisted.append("cases")
+
+ monkeypatch.setattr(post_content_worker, "_claim_job", claim)
+ monkeypatch.setattr(post_content_worker, "persist_post_content", fail_content)
+ monkeypatch.setattr(
+ post_content_worker,
+ "load_settings",
+ lambda: SimpleNamespace(
+ orchestrator_base_url="gateway", orchestrator_api_key="key"
+ ),
+ )
+ monkeypatch.setattr(
+ post_content_worker, "_operations_evidence_sources", evidence_sources
+ )
+ monkeypatch.setattr(
+ post_content_worker,
+ "ContextualOrchestratorOperationsCaseAnalysisClient",
+ lambda *_args: SimpleNamespace(analyze=lambda *_args: ()),
+ )
+ monkeypatch.setattr(post_content_worker, "persist_operations_cases", persist_cases)
+ monkeypatch.setattr(
+ post_content_worker, "normalize_post_body", lambda *_args: object()
+ )
+ client = SimpleNamespace(available=True)
+
+ asyncio.run(
+ post_content_worker.process_post_content_job(
+ pool,
+ post_id="00000000-0000-0000-0000-000000000001",
+ source_body_digest="a" * 64,
+ vision_factory=lambda: client,
+ embedding_factory=lambda: client,
+ structure_factory=lambda: client,
+ )
+ )
+
+ assert persisted == ["cases"]
+ updates = [
+ args for query, args in connection.executed if "set status_code" in query
+ ]
+ assert any(args[1] == QUEUED for args in updates)
+
+
def test_missing_source_body_is_not_reported_as_a_provider_failure(monkeypatch, caplog) -> None:
connection = _Connection(values=[2])
pool = _Pool(connection)
From e9876844dc7230b62175cf2cebccca222519e5b4 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 12:57:24 +0900
Subject: [PATCH 102/393] fix(worker): preserve completed evidence on sibling
retry outage
---
backend/app/post_content_worker.py | 10 +++++-
tests/test_post_content_worker.py | 55 ++++++++++++++++++++++++++++++
2 files changed, 64 insertions(+), 1 deletion(-)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 567236518..aaff5be0c 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -483,7 +483,15 @@ async def process_post_content_job(
and row.get("case_analysis_source_body_sha256")
!= source_body_digest
):
- await _requeue_project_missing_case_jobs(pool, post_id)
+ try:
+ await _requeue_project_missing_case_jobs(pool, post_id)
+ except Exception as exc: # noqa: BLE001 - primary evidence is complete.
+ _logger.error("project sibling requeue failed for post_id=%s", post_id)
+ record_server_failure(
+ "post_content_sibling_requeue",
+ exc,
+ outcome="provider_unavailable",
+ )
except Exception as exc: # noqa: BLE001 - durable failure is recorded for retry.
_logger.error("post content ingestion failed for post_id=%s", post_id)
outcome = (
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index b948814b6..bb2549937 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -391,6 +391,61 @@ def test_existing_case_analysis_skips_duplicate_orchestrator_call(monkeypatch) -
assert called == []
+def test_sibling_requeue_failure_preserves_completed_primary_job(monkeypatch) -> None:
+ """Ancillary retry discovery cannot fail already-persisted post evidence."""
+ outcomes: list[str] = []
+
+ async def claim(*_args, **_kwargs):
+ return _row(RUNNING, 1)
+
+ async def complete(*_args, **_kwargs):
+ return True
+
+ async def fail_requeue(*_args, **_kwargs):
+ raise OSError("synthetic sibling lookup outage")
+
+ async def finish(_pool, _post_id, status, **_kwargs):
+ outcomes.append(status)
+
+ monkeypatch.setattr(post_content_worker, "_claim_job", claim)
+ monkeypatch.setattr(
+ post_content_worker,
+ "load_settings",
+ lambda: SimpleNamespace(orchestrator_base_url="gateway", orchestrator_api_key="key"),
+ )
+ monkeypatch.setattr(
+ post_content_worker,
+ "_persist_operations_case_analysis_if_needed",
+ lambda *_args, **_kwargs: asyncio.sleep(0),
+ )
+ monkeypatch.setattr(post_content_worker, "normalize_post_body", lambda *_args: object())
+ monkeypatch.setattr(
+ post_content_worker,
+ "persist_post_content",
+ lambda *_args, **_kwargs: asyncio.sleep(0),
+ )
+ monkeypatch.setattr(post_content_worker, "post_content_is_complete", complete)
+ monkeypatch.setattr(post_content_worker, "_requeue_project_missing_case_jobs", fail_requeue)
+ monkeypatch.setattr(post_content_worker, "_finish_job", finish)
+ monkeypatch.setattr(
+ post_content_worker, "record_server_failure", lambda *_args, **_kwargs: None
+ )
+ client = SimpleNamespace(available=True, resolved_model="synthetic-model")
+
+ asyncio.run(
+ post_content_worker.process_post_content_job(
+ _Pool(_Connection()),
+ post_id="00000000-0000-0000-0000-000000000001",
+ source_body_digest="a" * 64,
+ vision_factory=lambda: client,
+ embedding_factory=lambda: client,
+ structure_factory=lambda: client,
+ )
+ )
+
+ assert outcomes == [SUCCEEDED]
+
+
def test_case_analysis_persists_before_content_provider_failure(monkeypatch) -> None:
"""Independent case evidence survives a later structure or embedding outage."""
connection = _Connection(values=[False, 2])
From 6ec74fff861d97ee62d014c6bc3f2e03c9afafe6 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 12:59:39 +0900
Subject: [PATCH 103/393] fix(operations): use orchestrator auto model selector
---
lineageweave/operations_case_analysis.py | 1 +
tests/test_operations_case_analysis.py | 20 ++++++++++++++++++++
2 files changed, 21 insertions(+)
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py
index 74ca912d3..c96261705 100644
--- a/lineageweave/operations_case_analysis.py
+++ b/lineageweave/operations_case_analysis.py
@@ -382,6 +382,7 @@ def analyze(
response = post_json(
f"{self._base_url}/v1/chat/completions",
{
+ "model": "auto",
"messages": [
{
"role": "user",
diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py
index a17e5a898..898f4f70a 100644
--- a/tests/test_operations_case_analysis.py
+++ b/tests/test_operations_case_analysis.py
@@ -2,12 +2,32 @@
import json
+from lineageweave import operations_case_analysis
from lineageweave.operations_case_analysis import (
+ ContextualOrchestratorOperationsCaseAnalysisClient,
OperationsEvidenceSource,
parse_operations_case_response,
)
+def test_orchestrator_request_uses_provider_neutral_auto_selector(monkeypatch) -> None:
+ """The consumer selects orchestrator routing, never a provider model name."""
+ captured: dict[str, object] = {}
+
+ def post_json(_url, payload, **_kwargs):
+ captured.update(payload)
+ return {"choices": [{"message": {"content": "[]"}}]}
+
+ monkeypatch.setattr(operations_case_analysis, "post_json", post_json)
+ client = ContextualOrchestratorOperationsCaseAnalysisClient("gateway", "key")
+
+ assert client.analyze(
+ (OperationsEvidenceSource("post-1", "Synthetic", "Synthetic source."),),
+ "",
+ ) == ()
+ assert captured["model"] == "auto"
+
+
def test_parses_multiple_cases_and_grounded_facts() -> None:
"""One record may support multiple case kinds without losing evidence."""
body = "The revised specification caused the claim. Mina agreed with Alex to rebid."
From 38899946121681c82280e38944252f83e6b1df2e Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:01:14 +0900
Subject: [PATCH 104/393] fix(compose): install orchestrator runtime dependency
---
docker/contextual-orchestrator/Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index d1cb4b45c..cb3f26f7f 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/052fdd841e912fbed3f646436f02d6b112161e18.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/3349edf3c30dce28f94356ee52ee10dc92e0ac22.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
From 891e0f9021dfd71a8b0455a9e4d627b8cd41b1fb Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:04:21 +0900
Subject: [PATCH 105/393] perf: bulk semantic embedding backfill
---
backend/app/post_content_worker.py | 162 +++++++++++++++++++++-
docker/contextual-orchestrator/Dockerfile | 2 +-
lineageweave/embedding_client.py | 18 ++-
lineageweave/post_content_persistence.py | 28 ++--
tests/test_post_content_persistence.py | 3 +
tests/test_post_content_worker.py | 49 +++++++
6 files changed, 241 insertions(+), 21 deletions(-)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 3fb08e423..afcc93ec0 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -31,7 +31,7 @@
find_project_sibling_post_ids,
gather_chat_sources,
)
-from lineageweave.embedding_client import EmbeddingClient
+from lineageweave.embedding_client import EmbeddingClient, NullEmbeddingClient
from lineageweave.http_client import HttpClientError
from lineageweave.image_content import ImageContentClient
from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata
@@ -389,7 +389,8 @@ async def process_post_content_job(
vision_factory: Callable[[], ImageContentClient],
embedding_factory: Callable[[], EmbeddingClient],
structure_factory: Callable[[], PostStructureClient],
-) -> None:
+ defer_embedding: bool = False,
+) -> int | None:
"""Claim, run, and record the outcome of one post-content ingestion job.
Claims the job for `post_id`/`source_body_digest` (a no-op if it is
@@ -428,7 +429,7 @@ async def process_post_content_job(
return
try:
metadata = build_post_llm_metadata(post_id, row)
- embedding_client = embedding_factory()
+ embedding_client = NullEmbeddingClient() if defer_embedding else embedding_factory()
structure_client = structure_factory()
with use_llm_metadata(metadata):
vision_client = vision_factory()
@@ -468,6 +469,8 @@ async def process_post_content_job(
require_embedding=require_orchestrator_evidence,
require_structure=require_orchestrator_evidence,
)
+ if defer_embedding:
+ return attempt_count
if not complete:
await _finish_failed_job(
pool,
@@ -511,6 +514,108 @@ async def process_post_content_job(
)
return
await _finish_job(pool, post_id, SUCCEEDED, expected_attempt_count=attempt_count)
+ return attempt_count
+
+
+async def _persist_bulk_embeddings(
+ pool: asyncpg.Pool, post_ids: list[str], embedding_client: EmbeddingClient
+) -> None:
+ """Embed all missing semantic units in one provenance-aligned bulk request."""
+ if not post_ids or not embedding_client.available:
+ return
+ async with pool.acquire() as conn:
+ rows = await conn.fetch(
+ """
+ select 'unit' as target_kind, unit.post_content_unit_id as target_id,
+ unit.unit_text as input_text, post.post_id,
+ post.author_account_id, post.corporate_entity_id, post.process_unit_id
+ from post_content_unit unit
+ join source_post post on post.post_id = unit.post_id
+ left join post_content_embedding embedding using (post_content_unit_id)
+ where unit.post_id = any($1::uuid[])
+ and nullif(btrim(unit.unit_text), '') is not null
+ and embedding.post_content_embedding_id is null
+ union all
+ select 'region', region.post_content_image_region_id,
+ concat_ws(' ', region.caption, region.extracted_text), post.post_id,
+ post.author_account_id, post.corporate_entity_id, post.process_unit_id
+ from post_content_image_region region
+ join post_content_image image using (post_content_image_id)
+ join post_content_unit unit using (post_content_unit_id)
+ join source_post post on post.post_id = unit.post_id
+ left join post_content_image_region_embedding embedding
+ using (post_content_image_region_id)
+ where unit.post_id = any($1::uuid[])
+ and region.description_status_code = 'described'
+ and nullif(btrim(concat_ws(' ', region.caption, region.extracted_text)), '') is not null
+ and embedding.post_content_image_region_embedding_id is null
+ order by post_id, target_kind, target_id
+ """,
+ [UUID(post_id) for post_id in post_ids],
+ )
+ if not rows:
+ return
+ embed_many = getattr(embedding_client, "embed_many", None)
+ if not callable(embed_many):
+ raise RuntimeError("bulk embedding client is unavailable")
+ vectors = await asyncio.to_thread(
+ embed_many,
+ [str(row["input_text"]) for row in rows],
+ input_metadata=[
+ {
+ "session_id": f"post:{row['post_id']}",
+ "post_id": str(row["post_id"]),
+ "target_kind": str(row["target_kind"]),
+ "target_id": str(row["target_id"]),
+ }
+ for row in rows
+ ],
+ input_attributions=[
+ {
+ key: str(value)
+ for key, value in {
+ "account": row["author_account_id"],
+ "team": row["process_unit_id"],
+ "company": row["corporate_entity_id"],
+ }.items()
+ if value is not None
+ }
+ for row in rows
+ ],
+ )
+ model = getattr(embedding_client, "resolved_model", None)
+ if not isinstance(model, str) or not model:
+ raise ValueError("bulk embedding response did not identify its model")
+ unit_dimensions: list[tuple[object, int, float]] = []
+ region_dimensions: list[tuple[object, int, float]] = []
+ async with pool.acquire() as conn, conn.transaction():
+ for row, vector in zip(rows, vectors, strict=True):
+ is_unit = row["target_kind"] == "unit"
+ embedding_id = await conn.fetchval(
+ (
+ "insert into post_content_embedding (post_content_unit_id, embedding_model_code, embedding_dimension_count) values ($1, $2, $3) returning post_content_embedding_id"
+ if is_unit
+ else "insert into post_content_image_region_embedding (post_content_image_region_id, embedding_model_code, embedding_dimension_count) values ($1, $2, $3) returning post_content_image_region_embedding_id"
+ ),
+ row["target_id"],
+ model,
+ len(vector),
+ )
+ target = unit_dimensions if is_unit else region_dimensions
+ target.extend(
+ (embedding_id, index, float(value))
+ for index, value in enumerate(vector)
+ )
+ if unit_dimensions:
+ await conn.executemany(
+ "insert into post_content_embedding_value (post_content_embedding_id, dimension_index, dimension_value) values ($1, $2, $3)",
+ unit_dimensions,
+ )
+ if region_dimensions:
+ await conn.executemany(
+ "insert into post_content_image_region_embedding_value (post_content_image_region_embedding_id, dimension_index, dimension_value) values ($1, $2, $3)",
+ region_dimensions,
+ )
async def consume_post_content_stream_once(
@@ -553,6 +658,9 @@ async def consume_post_content_stream_once(
"lineageweave.stream.kind": "post_content",
},
):
+ embedding_client = embedding_factory()
+ bulk_enabled = embedding_client.available
+ deferred: list[tuple[str, int]] = []
for _stream_name, entries in batches:
for entry_id, fields in entries:
post_id = str(fields.get("post_id", "")).strip()
@@ -562,15 +670,61 @@ async def consume_post_content_stream_once(
except ValueError:
post_id = ""
if post_id and len(digest) == 64:
- await process_post_content_job(
+ attempt_count = await process_post_content_job(
pool,
post_id=post_id,
source_body_digest=digest,
vision_factory=vision_factory,
embedding_factory=embedding_factory,
structure_factory=structure_factory,
+ defer_embedding=bulk_enabled,
)
+ if attempt_count is not None:
+ deferred.append((post_id, attempt_count))
last_id = str(entry_id)
+ if deferred:
+ try:
+ await _persist_bulk_embeddings(
+ pool, [post_id for post_id, _attempt in deferred], embedding_client
+ )
+ except Exception as exc: # noqa: BLE001 - each durable job is retryable.
+ record_server_failure("post_content_bulk_embedding", exc, outcome="provider_unavailable")
+ for post_id, attempt_count in deferred:
+ await _finish_failed_job(
+ pool,
+ post_id,
+ failure_code=_INCOMPLETE_FAILURE_CODE,
+ detail_text="bulk embedding did not produce complete persisted evidence",
+ expected_attempt_count=attempt_count,
+ )
+ else:
+ for post_id, attempt_count in deferred:
+ async with pool.acquire() as conn:
+ complete = await post_content_is_complete(
+ conn,
+ post_id,
+ embedding_model_code=getattr(embedding_client, "resolved_model", None),
+ require_embedding=True,
+ require_structure=True,
+ )
+ if complete:
+ await _finish_job(
+ pool, post_id, SUCCEEDED, expected_attempt_count=attempt_count
+ )
+ try:
+ await _requeue_project_missing_case_jobs(pool, post_id)
+ except Exception as exc: # noqa: BLE001 - primary evidence is complete.
+ record_server_failure(
+ "post_content_sibling_requeue", exc, outcome="provider_unavailable"
+ )
+ else:
+ await _finish_failed_job(
+ pool,
+ post_id,
+ failure_code=_INCOMPLETE_FAILURE_CODE,
+ detail_text="bulk embedding did not produce complete persisted evidence",
+ expected_attempt_count=attempt_count,
+ )
return last_id
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index cb3f26f7f..8b6a79a11 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/3349edf3c30dce28f94356ee52ee10dc92e0ac22.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/ae1b8c0d4941116b34c52f99df040e84f6568974.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index 3cb4d5975..ec32dade3 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -86,8 +86,14 @@ def embed(self, text: str) -> list[float]:
"""Return an embedding for the supplied text."""
return self.embed_many([text])[0]
- def embed_many(self, texts: list[str]) -> list[list[float]]:
- """Return embeddings for the supplied texts."""
+ def embed_many(
+ self,
+ texts: list[str],
+ *,
+ input_metadata: list[dict[str, object]] | None = None,
+ input_attributions: list[dict[str, object]] | None = None,
+ ) -> list[list[float]]:
+ """Return an index-aligned bulk embedding batch with optional provenance."""
if not texts:
return []
headers = {"authorization": f"Bearer {self._api_key}"}
@@ -98,6 +104,14 @@ def embed_many(self, texts: list[str]) -> list[list[float]]:
}
if self._model is not None:
payload["model"] = self._model
+ if input_metadata is not None:
+ if len(input_metadata) != len(texts):
+ raise ValueError("input_metadata must align with embedding inputs")
+ payload["input_metadata"] = input_metadata
+ if input_attributions is not None:
+ if len(input_attributions) != len(texts):
+ raise ValueError("input_attributions must align with embedding inputs")
+ payload["input_attributions"] = input_attributions
response = post_json(
f"{self._base_url}/batch/embeddings",
payload,
diff --git a/lineageweave/post_content_persistence.py b/lineageweave/post_content_persistence.py
index 5fb752cc4..5782815cb 100644
--- a/lineageweave/post_content_persistence.py
+++ b/lineageweave/post_content_persistence.py
@@ -405,13 +405,13 @@ async def persist_post_content(
embedding_model_code,
len(vector),
)
- for dimension_index, dimension_value in enumerate(vector):
- await conn.execute(
- "insert into post_content_image_region_embedding_value (post_content_image_region_embedding_id, dimension_index, dimension_value) values ($1, $2, $3)",
- region_embedding_id,
- dimension_index,
- dimension_value,
- )
+ await conn.executemany(
+ "insert into post_content_image_region_embedding_value (post_content_image_region_embedding_id, dimension_index, dimension_value) values ($1, $2, $3)",
+ [
+ (region_embedding_id, dimension_index, dimension_value)
+ for dimension_index, dimension_value in enumerate(vector)
+ ],
+ )
if embedding_model_code:
for embedding_key, vector in vectors.items():
@@ -429,11 +429,11 @@ async def persist_post_content(
embedding_model_code,
len(vector),
)
- for dimension_index, dimension_value in enumerate(vector):
- await conn.execute(
- "insert into post_content_embedding_value (post_content_embedding_id, dimension_index, dimension_value) values ($1, $2, $3)",
- embedding_id,
- dimension_index,
- dimension_value,
- )
+ await conn.executemany(
+ "insert into post_content_embedding_value (post_content_embedding_id, dimension_index, dimension_value) values ($1, $2, $3)",
+ [
+ (embedding_id, dimension_index, dimension_value)
+ for dimension_index, dimension_value in enumerate(vector)
+ ],
+ )
return len(prepared)
diff --git a/tests/test_post_content_persistence.py b/tests/test_post_content_persistence.py
index 5dd98f7af..54f3ae897 100644
--- a/tests/test_post_content_persistence.py
+++ b/tests/test_post_content_persistence.py
@@ -25,6 +25,9 @@ async def execute(self, query: str, *args: object) -> str:
self.executed.append((query, args))
return "OK"
+ async def executemany(self, query: str, args: list[tuple[object, ...]]) -> None:
+ self.executed.extend((query, row) for row in args)
+
async def fetchval(self, query: str, *args: object) -> str:
self.fetched.append(query)
if "post_content_unit" in query:
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index bb2549937..a9e0e91c4 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -55,6 +55,9 @@ async def execute(self, query: str, *args: object) -> str:
self.executed.append((query, args))
return "OK"
+ async def executemany(self, query: str, args: list[tuple[object, ...]]) -> None:
+ self.executed.extend((query, row) for row in args)
+
class _Pool:
def __init__(self, connection: _Connection):
@@ -86,6 +89,52 @@ async def xrevrange(self, key: str, *, count: int):
assert asyncio.run(post_content_worker._stream_tail(Client())) == "123-0"
+def test_bulk_embedding_uses_one_provenance_aligned_call_and_bulk_value_insert() -> None:
+ """Multiple posts share one call while retaining index-aligned provenance."""
+ post_ids = [
+ "00000000-0000-0000-0000-000000000001",
+ "00000000-0000-0000-0000-000000000002",
+ ]
+
+ class Connection(_Connection):
+ async def fetch(self, *_args: object):
+ return [
+ {
+ "target_kind": "unit",
+ "target_id": f"unit-{index}",
+ "input_text": f"synthetic unit {index}",
+ "post_id": UUID(post_id),
+ "author_account_id": f"account-{index}",
+ "corporate_entity_id": f"company-{index}",
+ "process_unit_id": f"team-{index}",
+ }
+ for index, post_id in enumerate(post_ids)
+ ]
+
+ async def fetchval(self, *_args: object):
+ return f"embedding-{len(self.executed)}"
+
+ class Embeddings:
+ available = True
+ resolved_model = "resolved-model"
+
+ def __init__(self) -> None:
+ self.calls: list[tuple[list[str], list[dict[str, object]]]] = []
+
+ def embed_many(self, texts, *, input_metadata, input_attributions):
+ self.calls.append((texts, input_metadata))
+ assert [item["team"] for item in input_attributions] == ["team-0", "team-1"]
+ return [[0.1, 0.2], [0.3, 0.4]]
+
+ connection = Connection()
+ client = Embeddings()
+ asyncio.run(post_content_worker._persist_bulk_embeddings(_Pool(connection), post_ids, client))
+
+ assert len(client.calls) == 1
+ assert [item["post_id"] for item in client.calls[0][1]] == post_ids
+ assert sum("post_content_embedding_value" in query for query, _args in connection.executed) == 4
+
+
def test_operations_sources_apply_focal_entity_and_process_scope(monkeypatch) -> None:
"""Private linked evidence outside the focal PU never reaches the orchestrator."""
decisions: list[bool] = []
From 7398b8e18805a2bb9133935a7228441f22d799cf Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:04:52 +0900
Subject: [PATCH 106/393] fix(operations): address orchestrator auto deployment
---
lineageweave/operations_case_analysis.py | 2 +-
tests/test_operations_case_analysis.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py
index c96261705..afd13beae 100644
--- a/lineageweave/operations_case_analysis.py
+++ b/lineageweave/operations_case_analysis.py
@@ -382,7 +382,7 @@ def analyze(
response = post_json(
f"{self._base_url}/v1/chat/completions",
{
- "model": "auto",
+ "model": "orchestrator/auto",
"messages": [
{
"role": "user",
diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py
index 898f4f70a..b9e744477 100644
--- a/tests/test_operations_case_analysis.py
+++ b/tests/test_operations_case_analysis.py
@@ -25,7 +25,7 @@ def post_json(_url, payload, **_kwargs):
(OperationsEvidenceSource("post-1", "Synthetic", "Synthetic source."),),
"",
) == ()
- assert captured["model"] == "auto"
+ assert captured["model"] == "orchestrator/auto"
def test_parses_multiple_cases_and_grounded_facts() -> None:
From 1d16c402d661f488801dddd648dd18f996344358 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:08:08 +0900
Subject: [PATCH 107/393] perf: prepare semantic backfill batch concurrently
---
backend/app/post_content_worker.py | 33 ++++++++++++++++++++----------
1 file changed, 22 insertions(+), 11 deletions(-)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index afcc93ec0..07c569147 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -5,7 +5,7 @@
import asyncio
import logging
import time
-from collections.abc import Callable
+from collections.abc import Awaitable, Callable
from uuid import UUID
import asyncpg
@@ -661,6 +661,7 @@ async def consume_post_content_stream_once(
embedding_client = embedding_factory()
bulk_enabled = embedding_client.available
deferred: list[tuple[str, int]] = []
+ pending: list[tuple[str, Awaitable[int | None]]] = []
for _stream_name, entries in batches:
for entry_id, fields in entries:
post_id = str(fields.get("post_id", "")).strip()
@@ -670,18 +671,28 @@ async def consume_post_content_stream_once(
except ValueError:
post_id = ""
if post_id and len(digest) == 64:
- attempt_count = await process_post_content_job(
- pool,
- post_id=post_id,
- source_body_digest=digest,
- vision_factory=vision_factory,
- embedding_factory=embedding_factory,
- structure_factory=structure_factory,
- defer_embedding=bulk_enabled,
+ pending.append(
+ (
+ post_id,
+ process_post_content_job(
+ pool,
+ post_id=post_id,
+ source_body_digest=digest,
+ vision_factory=vision_factory,
+ embedding_factory=embedding_factory,
+ structure_factory=structure_factory,
+ defer_embedding=bulk_enabled,
+ ),
+ )
)
- if attempt_count is not None:
- deferred.append((post_id, attempt_count))
last_id = str(entry_id)
+ if pending:
+ attempt_counts = await asyncio.gather(*(job for _post_id, job in pending))
+ deferred.extend(
+ (post_id, attempt_count)
+ for (post_id, _job), attempt_count in zip(pending, attempt_counts, strict=True)
+ if attempt_count is not None
+ )
if deferred:
try:
await _persist_bulk_embeddings(
From a7cda9b3434fa75a3b49aca541fb5a6a670c229b Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:09:53 +0900
Subject: [PATCH 108/393] fix: isolate operations analysis from embedding batch
---
backend/app/post_content_worker.py | 78 ++++++++++++++++++++----------
1 file changed, 53 insertions(+), 25 deletions(-)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 07c569147..2ff2a7a74 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -390,7 +390,7 @@ async def process_post_content_job(
embedding_factory: Callable[[], EmbeddingClient],
structure_factory: Callable[[], PostStructureClient],
defer_embedding: bool = False,
-) -> int | None:
+) -> tuple[int, asyncio.Task[None] | None] | None:
"""Claim, run, and record the outcome of one post-content ingestion job.
Claims the job for `post_id`/`source_body_digest` (a no-op if it is
@@ -427,6 +427,7 @@ async def process_post_content_job(
expected_attempt_count=attempt_count,
)
return
+ operations_task: asyncio.Task[None] | None = None
try:
metadata = build_post_llm_metadata(post_id, row)
embedding_client = NullEmbeddingClient() if defer_embedding else embedding_factory()
@@ -434,16 +435,18 @@ async def process_post_content_job(
with use_llm_metadata(metadata):
vision_client = vision_factory()
if settings.orchestrator_base_url and settings.orchestrator_api_key:
- await _persist_operations_case_analysis_if_needed(
- pool,
- post_id,
- source_body_digest,
- raw_body,
- row,
- vision_client,
- metadata["lineageweave_post_session_id"],
- settings.orchestrator_base_url,
- settings.orchestrator_api_key,
+ operations_task = asyncio.create_task(
+ _persist_operations_case_analysis_if_needed(
+ pool,
+ post_id,
+ source_body_digest,
+ raw_body,
+ row,
+ vision_client,
+ metadata["lineageweave_post_session_id"],
+ settings.orchestrator_base_url,
+ settings.orchestrator_api_key,
+ )
)
normalized = await asyncio.to_thread(
normalize_post_body, raw_body, vision_client
@@ -470,7 +473,9 @@ async def process_post_content_job(
require_structure=require_orchestrator_evidence,
)
if defer_embedding:
- return attempt_count
+ return attempt_count, operations_task
+ if operations_task is not None:
+ await operations_task
if not complete:
await _finish_failed_job(
pool,
@@ -496,6 +501,11 @@ async def process_post_content_job(
outcome="provider_unavailable",
)
except Exception as exc: # noqa: BLE001 - durable failure is recorded for retry.
+ if operations_task is not None:
+ try:
+ await operations_task
+ except Exception: # noqa: BLE001 - the content failure remains the durable retry cause.
+ pass
_logger.error("post content ingestion failed for post_id=%s", post_id)
outcome = (
"provider_unavailable"
@@ -514,7 +524,7 @@ async def process_post_content_job(
)
return
await _finish_job(pool, post_id, SUCCEEDED, expected_attempt_count=attempt_count)
- return attempt_count
+ return attempt_count, None
async def _persist_bulk_embeddings(
@@ -660,8 +670,10 @@ async def consume_post_content_stream_once(
):
embedding_client = embedding_factory()
bulk_enabled = embedding_client.available
- deferred: list[tuple[str, int]] = []
- pending: list[tuple[str, Awaitable[int | None]]] = []
+ deferred: list[tuple[str, int, asyncio.Task[None] | None]] = []
+ pending: list[
+ tuple[str, Awaitable[tuple[int, asyncio.Task[None] | None] | None]]
+ ] = []
for _stream_name, entries in batches:
for entry_id, fields in entries:
post_id = str(fields.get("post_id", "")).strip()
@@ -689,18 +701,25 @@ async def consume_post_content_stream_once(
if pending:
attempt_counts = await asyncio.gather(*(job for _post_id, job in pending))
deferred.extend(
- (post_id, attempt_count)
- for (post_id, _job), attempt_count in zip(pending, attempt_counts, strict=True)
- if attempt_count is not None
+ (post_id, result[0], result[1])
+ for (post_id, _job), result in zip(pending, attempt_counts, strict=True)
+ if result is not None
)
if deferred:
- try:
- await _persist_bulk_embeddings(
- pool, [post_id for post_id, _attempt in deferred], embedding_client
+ operations_tasks = [task for _post_id, _attempt, task in deferred if task is not None]
+ results = await asyncio.gather(
+ _persist_bulk_embeddings(
+ pool, [post_id for post_id, _attempt, _task in deferred], embedding_client
+ ),
+ *operations_tasks,
+ return_exceptions=True,
+ )
+ bulk_error = results[0] if isinstance(results[0], Exception) else None
+ if bulk_error is not None:
+ record_server_failure(
+ "post_content_bulk_embedding", bulk_error, outcome="provider_unavailable"
)
- except Exception as exc: # noqa: BLE001 - each durable job is retryable.
- record_server_failure("post_content_bulk_embedding", exc, outcome="provider_unavailable")
- for post_id, attempt_count in deferred:
+ for post_id, attempt_count, _task in deferred:
await _finish_failed_job(
pool,
post_id,
@@ -709,7 +728,16 @@ async def consume_post_content_stream_once(
expected_attempt_count=attempt_count,
)
else:
- for post_id, attempt_count in deferred:
+ for post_id, attempt_count, operations_task in deferred:
+ if operations_task is not None and operations_task.exception() is not None:
+ await _finish_failed_job(
+ pool,
+ post_id,
+ failure_code="operations_case_analysis_failed",
+ detail_text="operations analysis did not produce persisted evidence",
+ expected_attempt_count=attempt_count,
+ )
+ continue
async with pool.acquire() as conn:
complete = await post_content_is_complete(
conn,
From c5e65907e2765d461a284438d295b5a40b5ec407 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:12:02 +0900
Subject: [PATCH 109/393] Revert "fix: isolate operations analysis from
embedding batch"
This reverts commit a7cda9b3434fa75a3b49aca541fb5a6a670c229b.
---
backend/app/post_content_worker.py | 78 ++++++++++--------------------
1 file changed, 25 insertions(+), 53 deletions(-)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 2ff2a7a74..07c569147 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -390,7 +390,7 @@ async def process_post_content_job(
embedding_factory: Callable[[], EmbeddingClient],
structure_factory: Callable[[], PostStructureClient],
defer_embedding: bool = False,
-) -> tuple[int, asyncio.Task[None] | None] | None:
+) -> int | None:
"""Claim, run, and record the outcome of one post-content ingestion job.
Claims the job for `post_id`/`source_body_digest` (a no-op if it is
@@ -427,7 +427,6 @@ async def process_post_content_job(
expected_attempt_count=attempt_count,
)
return
- operations_task: asyncio.Task[None] | None = None
try:
metadata = build_post_llm_metadata(post_id, row)
embedding_client = NullEmbeddingClient() if defer_embedding else embedding_factory()
@@ -435,18 +434,16 @@ async def process_post_content_job(
with use_llm_metadata(metadata):
vision_client = vision_factory()
if settings.orchestrator_base_url and settings.orchestrator_api_key:
- operations_task = asyncio.create_task(
- _persist_operations_case_analysis_if_needed(
- pool,
- post_id,
- source_body_digest,
- raw_body,
- row,
- vision_client,
- metadata["lineageweave_post_session_id"],
- settings.orchestrator_base_url,
- settings.orchestrator_api_key,
- )
+ await _persist_operations_case_analysis_if_needed(
+ pool,
+ post_id,
+ source_body_digest,
+ raw_body,
+ row,
+ vision_client,
+ metadata["lineageweave_post_session_id"],
+ settings.orchestrator_base_url,
+ settings.orchestrator_api_key,
)
normalized = await asyncio.to_thread(
normalize_post_body, raw_body, vision_client
@@ -473,9 +470,7 @@ async def process_post_content_job(
require_structure=require_orchestrator_evidence,
)
if defer_embedding:
- return attempt_count, operations_task
- if operations_task is not None:
- await operations_task
+ return attempt_count
if not complete:
await _finish_failed_job(
pool,
@@ -501,11 +496,6 @@ async def process_post_content_job(
outcome="provider_unavailable",
)
except Exception as exc: # noqa: BLE001 - durable failure is recorded for retry.
- if operations_task is not None:
- try:
- await operations_task
- except Exception: # noqa: BLE001 - the content failure remains the durable retry cause.
- pass
_logger.error("post content ingestion failed for post_id=%s", post_id)
outcome = (
"provider_unavailable"
@@ -524,7 +514,7 @@ async def process_post_content_job(
)
return
await _finish_job(pool, post_id, SUCCEEDED, expected_attempt_count=attempt_count)
- return attempt_count, None
+ return attempt_count
async def _persist_bulk_embeddings(
@@ -670,10 +660,8 @@ async def consume_post_content_stream_once(
):
embedding_client = embedding_factory()
bulk_enabled = embedding_client.available
- deferred: list[tuple[str, int, asyncio.Task[None] | None]] = []
- pending: list[
- tuple[str, Awaitable[tuple[int, asyncio.Task[None] | None] | None]]
- ] = []
+ deferred: list[tuple[str, int]] = []
+ pending: list[tuple[str, Awaitable[int | None]]] = []
for _stream_name, entries in batches:
for entry_id, fields in entries:
post_id = str(fields.get("post_id", "")).strip()
@@ -701,25 +689,18 @@ async def consume_post_content_stream_once(
if pending:
attempt_counts = await asyncio.gather(*(job for _post_id, job in pending))
deferred.extend(
- (post_id, result[0], result[1])
- for (post_id, _job), result in zip(pending, attempt_counts, strict=True)
- if result is not None
+ (post_id, attempt_count)
+ for (post_id, _job), attempt_count in zip(pending, attempt_counts, strict=True)
+ if attempt_count is not None
)
if deferred:
- operations_tasks = [task for _post_id, _attempt, task in deferred if task is not None]
- results = await asyncio.gather(
- _persist_bulk_embeddings(
- pool, [post_id for post_id, _attempt, _task in deferred], embedding_client
- ),
- *operations_tasks,
- return_exceptions=True,
- )
- bulk_error = results[0] if isinstance(results[0], Exception) else None
- if bulk_error is not None:
- record_server_failure(
- "post_content_bulk_embedding", bulk_error, outcome="provider_unavailable"
+ try:
+ await _persist_bulk_embeddings(
+ pool, [post_id for post_id, _attempt in deferred], embedding_client
)
- for post_id, attempt_count, _task in deferred:
+ except Exception as exc: # noqa: BLE001 - each durable job is retryable.
+ record_server_failure("post_content_bulk_embedding", exc, outcome="provider_unavailable")
+ for post_id, attempt_count in deferred:
await _finish_failed_job(
pool,
post_id,
@@ -728,16 +709,7 @@ async def consume_post_content_stream_once(
expected_attempt_count=attempt_count,
)
else:
- for post_id, attempt_count, operations_task in deferred:
- if operations_task is not None and operations_task.exception() is not None:
- await _finish_failed_job(
- pool,
- post_id,
- failure_code="operations_case_analysis_failed",
- detail_text="operations analysis did not produce persisted evidence",
- expected_attempt_count=attempt_count,
- )
- continue
+ for post_id, attempt_count in deferred:
async with pool.acquire() as conn:
complete = await post_content_is_complete(
conn,
From 87b3bd09f151f24bdebc6ea93d0ffe241a568969 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:12:02 +0900
Subject: [PATCH 110/393] Revert "perf: prepare semantic backfill batch
concurrently"
This reverts commit 1d16c402d661f488801dddd648dd18f996344358.
---
backend/app/post_content_worker.py | 33 ++++++++++--------------------
1 file changed, 11 insertions(+), 22 deletions(-)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 07c569147..afcc93ec0 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -5,7 +5,7 @@
import asyncio
import logging
import time
-from collections.abc import Awaitable, Callable
+from collections.abc import Callable
from uuid import UUID
import asyncpg
@@ -661,7 +661,6 @@ async def consume_post_content_stream_once(
embedding_client = embedding_factory()
bulk_enabled = embedding_client.available
deferred: list[tuple[str, int]] = []
- pending: list[tuple[str, Awaitable[int | None]]] = []
for _stream_name, entries in batches:
for entry_id, fields in entries:
post_id = str(fields.get("post_id", "")).strip()
@@ -671,28 +670,18 @@ async def consume_post_content_stream_once(
except ValueError:
post_id = ""
if post_id and len(digest) == 64:
- pending.append(
- (
- post_id,
- process_post_content_job(
- pool,
- post_id=post_id,
- source_body_digest=digest,
- vision_factory=vision_factory,
- embedding_factory=embedding_factory,
- structure_factory=structure_factory,
- defer_embedding=bulk_enabled,
- ),
- )
+ attempt_count = await process_post_content_job(
+ pool,
+ post_id=post_id,
+ source_body_digest=digest,
+ vision_factory=vision_factory,
+ embedding_factory=embedding_factory,
+ structure_factory=structure_factory,
+ defer_embedding=bulk_enabled,
)
+ if attempt_count is not None:
+ deferred.append((post_id, attempt_count))
last_id = str(entry_id)
- if pending:
- attempt_counts = await asyncio.gather(*(job for _post_id, job in pending))
- deferred.extend(
- (post_id, attempt_count)
- for (post_id, _job), attempt_count in zip(pending, attempt_counts, strict=True)
- if attempt_count is not None
- )
if deferred:
try:
await _persist_bulk_embeddings(
From e27c605f23881c36bd1349ba74940ff38858e984 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:12:02 +0900
Subject: [PATCH 111/393] Revert "perf: bulk semantic embedding backfill"
This reverts commit 891e0f9021dfd71a8b0455a9e4d627b8cd41b1fb.
---
backend/app/post_content_worker.py | 162 +---------------------
docker/contextual-orchestrator/Dockerfile | 2 +-
lineageweave/embedding_client.py | 18 +--
lineageweave/post_content_persistence.py | 28 ++--
tests/test_post_content_persistence.py | 3 -
tests/test_post_content_worker.py | 49 -------
6 files changed, 21 insertions(+), 241 deletions(-)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index afcc93ec0..3fb08e423 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -31,7 +31,7 @@
find_project_sibling_post_ids,
gather_chat_sources,
)
-from lineageweave.embedding_client import EmbeddingClient, NullEmbeddingClient
+from lineageweave.embedding_client import EmbeddingClient
from lineageweave.http_client import HttpClientError
from lineageweave.image_content import ImageContentClient
from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata
@@ -389,8 +389,7 @@ async def process_post_content_job(
vision_factory: Callable[[], ImageContentClient],
embedding_factory: Callable[[], EmbeddingClient],
structure_factory: Callable[[], PostStructureClient],
- defer_embedding: bool = False,
-) -> int | None:
+) -> None:
"""Claim, run, and record the outcome of one post-content ingestion job.
Claims the job for `post_id`/`source_body_digest` (a no-op if it is
@@ -429,7 +428,7 @@ async def process_post_content_job(
return
try:
metadata = build_post_llm_metadata(post_id, row)
- embedding_client = NullEmbeddingClient() if defer_embedding else embedding_factory()
+ embedding_client = embedding_factory()
structure_client = structure_factory()
with use_llm_metadata(metadata):
vision_client = vision_factory()
@@ -469,8 +468,6 @@ async def process_post_content_job(
require_embedding=require_orchestrator_evidence,
require_structure=require_orchestrator_evidence,
)
- if defer_embedding:
- return attempt_count
if not complete:
await _finish_failed_job(
pool,
@@ -514,108 +511,6 @@ async def process_post_content_job(
)
return
await _finish_job(pool, post_id, SUCCEEDED, expected_attempt_count=attempt_count)
- return attempt_count
-
-
-async def _persist_bulk_embeddings(
- pool: asyncpg.Pool, post_ids: list[str], embedding_client: EmbeddingClient
-) -> None:
- """Embed all missing semantic units in one provenance-aligned bulk request."""
- if not post_ids or not embedding_client.available:
- return
- async with pool.acquire() as conn:
- rows = await conn.fetch(
- """
- select 'unit' as target_kind, unit.post_content_unit_id as target_id,
- unit.unit_text as input_text, post.post_id,
- post.author_account_id, post.corporate_entity_id, post.process_unit_id
- from post_content_unit unit
- join source_post post on post.post_id = unit.post_id
- left join post_content_embedding embedding using (post_content_unit_id)
- where unit.post_id = any($1::uuid[])
- and nullif(btrim(unit.unit_text), '') is not null
- and embedding.post_content_embedding_id is null
- union all
- select 'region', region.post_content_image_region_id,
- concat_ws(' ', region.caption, region.extracted_text), post.post_id,
- post.author_account_id, post.corporate_entity_id, post.process_unit_id
- from post_content_image_region region
- join post_content_image image using (post_content_image_id)
- join post_content_unit unit using (post_content_unit_id)
- join source_post post on post.post_id = unit.post_id
- left join post_content_image_region_embedding embedding
- using (post_content_image_region_id)
- where unit.post_id = any($1::uuid[])
- and region.description_status_code = 'described'
- and nullif(btrim(concat_ws(' ', region.caption, region.extracted_text)), '') is not null
- and embedding.post_content_image_region_embedding_id is null
- order by post_id, target_kind, target_id
- """,
- [UUID(post_id) for post_id in post_ids],
- )
- if not rows:
- return
- embed_many = getattr(embedding_client, "embed_many", None)
- if not callable(embed_many):
- raise RuntimeError("bulk embedding client is unavailable")
- vectors = await asyncio.to_thread(
- embed_many,
- [str(row["input_text"]) for row in rows],
- input_metadata=[
- {
- "session_id": f"post:{row['post_id']}",
- "post_id": str(row["post_id"]),
- "target_kind": str(row["target_kind"]),
- "target_id": str(row["target_id"]),
- }
- for row in rows
- ],
- input_attributions=[
- {
- key: str(value)
- for key, value in {
- "account": row["author_account_id"],
- "team": row["process_unit_id"],
- "company": row["corporate_entity_id"],
- }.items()
- if value is not None
- }
- for row in rows
- ],
- )
- model = getattr(embedding_client, "resolved_model", None)
- if not isinstance(model, str) or not model:
- raise ValueError("bulk embedding response did not identify its model")
- unit_dimensions: list[tuple[object, int, float]] = []
- region_dimensions: list[tuple[object, int, float]] = []
- async with pool.acquire() as conn, conn.transaction():
- for row, vector in zip(rows, vectors, strict=True):
- is_unit = row["target_kind"] == "unit"
- embedding_id = await conn.fetchval(
- (
- "insert into post_content_embedding (post_content_unit_id, embedding_model_code, embedding_dimension_count) values ($1, $2, $3) returning post_content_embedding_id"
- if is_unit
- else "insert into post_content_image_region_embedding (post_content_image_region_id, embedding_model_code, embedding_dimension_count) values ($1, $2, $3) returning post_content_image_region_embedding_id"
- ),
- row["target_id"],
- model,
- len(vector),
- )
- target = unit_dimensions if is_unit else region_dimensions
- target.extend(
- (embedding_id, index, float(value))
- for index, value in enumerate(vector)
- )
- if unit_dimensions:
- await conn.executemany(
- "insert into post_content_embedding_value (post_content_embedding_id, dimension_index, dimension_value) values ($1, $2, $3)",
- unit_dimensions,
- )
- if region_dimensions:
- await conn.executemany(
- "insert into post_content_image_region_embedding_value (post_content_image_region_embedding_id, dimension_index, dimension_value) values ($1, $2, $3)",
- region_dimensions,
- )
async def consume_post_content_stream_once(
@@ -658,9 +553,6 @@ async def consume_post_content_stream_once(
"lineageweave.stream.kind": "post_content",
},
):
- embedding_client = embedding_factory()
- bulk_enabled = embedding_client.available
- deferred: list[tuple[str, int]] = []
for _stream_name, entries in batches:
for entry_id, fields in entries:
post_id = str(fields.get("post_id", "")).strip()
@@ -670,61 +562,15 @@ async def consume_post_content_stream_once(
except ValueError:
post_id = ""
if post_id and len(digest) == 64:
- attempt_count = await process_post_content_job(
+ await process_post_content_job(
pool,
post_id=post_id,
source_body_digest=digest,
vision_factory=vision_factory,
embedding_factory=embedding_factory,
structure_factory=structure_factory,
- defer_embedding=bulk_enabled,
)
- if attempt_count is not None:
- deferred.append((post_id, attempt_count))
last_id = str(entry_id)
- if deferred:
- try:
- await _persist_bulk_embeddings(
- pool, [post_id for post_id, _attempt in deferred], embedding_client
- )
- except Exception as exc: # noqa: BLE001 - each durable job is retryable.
- record_server_failure("post_content_bulk_embedding", exc, outcome="provider_unavailable")
- for post_id, attempt_count in deferred:
- await _finish_failed_job(
- pool,
- post_id,
- failure_code=_INCOMPLETE_FAILURE_CODE,
- detail_text="bulk embedding did not produce complete persisted evidence",
- expected_attempt_count=attempt_count,
- )
- else:
- for post_id, attempt_count in deferred:
- async with pool.acquire() as conn:
- complete = await post_content_is_complete(
- conn,
- post_id,
- embedding_model_code=getattr(embedding_client, "resolved_model", None),
- require_embedding=True,
- require_structure=True,
- )
- if complete:
- await _finish_job(
- pool, post_id, SUCCEEDED, expected_attempt_count=attempt_count
- )
- try:
- await _requeue_project_missing_case_jobs(pool, post_id)
- except Exception as exc: # noqa: BLE001 - primary evidence is complete.
- record_server_failure(
- "post_content_sibling_requeue", exc, outcome="provider_unavailable"
- )
- else:
- await _finish_failed_job(
- pool,
- post_id,
- failure_code=_INCOMPLETE_FAILURE_CODE,
- detail_text="bulk embedding did not produce complete persisted evidence",
- expected_attempt_count=attempt_count,
- )
return last_id
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 8b6a79a11..cb3f26f7f 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/ae1b8c0d4941116b34c52f99df040e84f6568974.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/3349edf3c30dce28f94356ee52ee10dc92e0ac22.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index ec32dade3..3cb4d5975 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -86,14 +86,8 @@ def embed(self, text: str) -> list[float]:
"""Return an embedding for the supplied text."""
return self.embed_many([text])[0]
- def embed_many(
- self,
- texts: list[str],
- *,
- input_metadata: list[dict[str, object]] | None = None,
- input_attributions: list[dict[str, object]] | None = None,
- ) -> list[list[float]]:
- """Return an index-aligned bulk embedding batch with optional provenance."""
+ def embed_many(self, texts: list[str]) -> list[list[float]]:
+ """Return embeddings for the supplied texts."""
if not texts:
return []
headers = {"authorization": f"Bearer {self._api_key}"}
@@ -104,14 +98,6 @@ def embed_many(
}
if self._model is not None:
payload["model"] = self._model
- if input_metadata is not None:
- if len(input_metadata) != len(texts):
- raise ValueError("input_metadata must align with embedding inputs")
- payload["input_metadata"] = input_metadata
- if input_attributions is not None:
- if len(input_attributions) != len(texts):
- raise ValueError("input_attributions must align with embedding inputs")
- payload["input_attributions"] = input_attributions
response = post_json(
f"{self._base_url}/batch/embeddings",
payload,
diff --git a/lineageweave/post_content_persistence.py b/lineageweave/post_content_persistence.py
index 5782815cb..5fb752cc4 100644
--- a/lineageweave/post_content_persistence.py
+++ b/lineageweave/post_content_persistence.py
@@ -405,13 +405,13 @@ async def persist_post_content(
embedding_model_code,
len(vector),
)
- await conn.executemany(
- "insert into post_content_image_region_embedding_value (post_content_image_region_embedding_id, dimension_index, dimension_value) values ($1, $2, $3)",
- [
- (region_embedding_id, dimension_index, dimension_value)
- for dimension_index, dimension_value in enumerate(vector)
- ],
- )
+ for dimension_index, dimension_value in enumerate(vector):
+ await conn.execute(
+ "insert into post_content_image_region_embedding_value (post_content_image_region_embedding_id, dimension_index, dimension_value) values ($1, $2, $3)",
+ region_embedding_id,
+ dimension_index,
+ dimension_value,
+ )
if embedding_model_code:
for embedding_key, vector in vectors.items():
@@ -429,11 +429,11 @@ async def persist_post_content(
embedding_model_code,
len(vector),
)
- await conn.executemany(
- "insert into post_content_embedding_value (post_content_embedding_id, dimension_index, dimension_value) values ($1, $2, $3)",
- [
- (embedding_id, dimension_index, dimension_value)
- for dimension_index, dimension_value in enumerate(vector)
- ],
- )
+ for dimension_index, dimension_value in enumerate(vector):
+ await conn.execute(
+ "insert into post_content_embedding_value (post_content_embedding_id, dimension_index, dimension_value) values ($1, $2, $3)",
+ embedding_id,
+ dimension_index,
+ dimension_value,
+ )
return len(prepared)
diff --git a/tests/test_post_content_persistence.py b/tests/test_post_content_persistence.py
index 54f3ae897..5dd98f7af 100644
--- a/tests/test_post_content_persistence.py
+++ b/tests/test_post_content_persistence.py
@@ -25,9 +25,6 @@ async def execute(self, query: str, *args: object) -> str:
self.executed.append((query, args))
return "OK"
- async def executemany(self, query: str, args: list[tuple[object, ...]]) -> None:
- self.executed.extend((query, row) for row in args)
-
async def fetchval(self, query: str, *args: object) -> str:
self.fetched.append(query)
if "post_content_unit" in query:
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index a9e0e91c4..bb2549937 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -55,9 +55,6 @@ async def execute(self, query: str, *args: object) -> str:
self.executed.append((query, args))
return "OK"
- async def executemany(self, query: str, args: list[tuple[object, ...]]) -> None:
- self.executed.extend((query, row) for row in args)
-
class _Pool:
def __init__(self, connection: _Connection):
@@ -89,52 +86,6 @@ async def xrevrange(self, key: str, *, count: int):
assert asyncio.run(post_content_worker._stream_tail(Client())) == "123-0"
-def test_bulk_embedding_uses_one_provenance_aligned_call_and_bulk_value_insert() -> None:
- """Multiple posts share one call while retaining index-aligned provenance."""
- post_ids = [
- "00000000-0000-0000-0000-000000000001",
- "00000000-0000-0000-0000-000000000002",
- ]
-
- class Connection(_Connection):
- async def fetch(self, *_args: object):
- return [
- {
- "target_kind": "unit",
- "target_id": f"unit-{index}",
- "input_text": f"synthetic unit {index}",
- "post_id": UUID(post_id),
- "author_account_id": f"account-{index}",
- "corporate_entity_id": f"company-{index}",
- "process_unit_id": f"team-{index}",
- }
- for index, post_id in enumerate(post_ids)
- ]
-
- async def fetchval(self, *_args: object):
- return f"embedding-{len(self.executed)}"
-
- class Embeddings:
- available = True
- resolved_model = "resolved-model"
-
- def __init__(self) -> None:
- self.calls: list[tuple[list[str], list[dict[str, object]]]] = []
-
- def embed_many(self, texts, *, input_metadata, input_attributions):
- self.calls.append((texts, input_metadata))
- assert [item["team"] for item in input_attributions] == ["team-0", "team-1"]
- return [[0.1, 0.2], [0.3, 0.4]]
-
- connection = Connection()
- client = Embeddings()
- asyncio.run(post_content_worker._persist_bulk_embeddings(_Pool(connection), post_ids, client))
-
- assert len(client.calls) == 1
- assert [item["post_id"] for item in client.calls[0][1]] == post_ids
- assert sum("post_content_embedding_value" in query for query, _args in connection.executed) == 4
-
-
def test_operations_sources_apply_focal_entity_and_process_scope(monkeypatch) -> None:
"""Private linked evidence outside the focal PU never reaches the orchestrator."""
decisions: list[bool] = []
From da8ec79ea2ad8a3484c180edc2aaa928df50dd85 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:12:56 +0900
Subject: [PATCH 112/393] fix(runtime): pin bounded provider failover
---
docker/contextual-orchestrator/Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index cb3f26f7f..d2ab83d29 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/3349edf3c30dce28f94356ee52ee10dc92e0ac22.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/d9c62be9feea24fdaeb8453f3c72f2c2b0237143.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
From 5b874f4dfae78a674413835ec1f48016822cbceb Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:13:45 +0900
Subject: [PATCH 113/393] fix(operations): invalidate stale evidence windows
---
CHANGELOG.md | 7 ++-
backend/app/operations_case_ingestion.py | 5 +-
backend/app/post_content_worker.py | 39 +++++++------
.../0083-orchestrator-runtime-commit-pin.md | 4 +-
.../adr/0206-evidence-operations-dashboard.md | 14 +++--
docs/product-technical-gap-baseline.md | 2 +-
lineageweave/operations_case_analysis.py | 21 +++++++
.../0222_operations_case_analysis_input.sql | 11 ++++
tests/test_documentation_hygiene.py | 2 +-
tests/test_operations_case_analysis.py | 20 +++++++
tests/test_operations_case_ingestion.py | 21 +++++--
tests/test_post_content_worker.py | 56 ++++++++++++++++++-
tests/test_schema.py | 24 ++++++++
13 files changed, 193 insertions(+), 33 deletions(-)
create mode 100644 migrations/0222_operations_case_analysis_input.sql
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac88db98d..a8c301f56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,8 +12,11 @@ All notable changes to this project are documented here. Format follows
part of its authorized evidence window before re-analysis. Missing evidence
remains a retry state rather than a request for the reader to attach an
original; newly analyzed project evidence now requeues completed sibling
- analyses that still have missing facts. The composer now uses an accessible
- form with a stable action and separate progress status.
+ analyses that still have missing facts. Case-analysis reuse now binds to a
+ digest of the exact ordered authorized evidence window and context, so an
+ unchanged focal body is re-analyzed when new sibling evidence arrives. The
+ composer now uses an accessible form with a stable action and separate
+ progress status.
- Docker Compose now has one canonical `lineageweave` project containing the
complete synthetic product stack. The backend receives the existing TEPP API
diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py
index f6b1bbed8..becfc90ad 100644
--- a/backend/app/operations_case_ingestion.py
+++ b/backend/app/operations_case_ingestion.py
@@ -33,6 +33,8 @@ async def persist_operations_cases(
source_body: str,
orchestrator_session_id: str,
cases: tuple[OperationsCase, ...],
+ *,
+ analysis_input_sha256: str,
) -> None:
"""Atomically replace one post's normalized case analysis."""
async with conn.transaction():
@@ -40,10 +42,11 @@ async def persist_operations_cases(
"delete from operations_case_analysis where post_id = $1", post_id
)
await conn.execute(
- "insert into operations_case_analysis (post_id, source_body_sha256, orchestrator_session_id) values ($1, $2, $3)",
+ "insert into operations_case_analysis (post_id, source_body_sha256, orchestrator_session_id, analysis_input_sha256) values ($1, $2, $3, $4)",
post_id,
source_body_sha256(source_body),
orchestrator_session_id,
+ analysis_input_sha256,
)
for case in cases:
await conn.execute(
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 3fb08e423..daf46fee3 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -39,6 +39,7 @@
from lineageweave.operations_case_analysis import (
ContextualOrchestratorOperationsCaseAnalysisClient,
OperationsEvidenceSource,
+ operations_analysis_input_sha256,
)
from lineageweave.post_content_normalization import normalize_post_body
from lineageweave.post_content_persistence import persist_post_content
@@ -121,22 +122,7 @@ async def _persist_operations_case_analysis_if_needed(
orchestrator_base_url: str,
orchestrator_api_key: str,
) -> None:
- """Persist evidence-bound cases once per exact source-body version."""
- async with pool.acquire() as conn:
- already_persisted = bool(
- await conn.fetchval(
- "select exists (select 1 from operations_case_analysis "
- "where post_id = $1 and source_body_sha256 = $2)",
- post_id,
- source_body_digest,
- )
- )
- if already_persisted:
- return
- case_client = ContextualOrchestratorOperationsCaseAnalysisClient(
- orchestrator_base_url,
- orchestrator_api_key,
- )
+ """Persist cases once per exact focal body and authorized evidence window."""
context = " | ".join(
f"{name}={row[name]}"
for name in (
@@ -151,6 +137,26 @@ async def _persist_operations_case_analysis_if_needed(
evidence_sources = await _operations_evidence_sources(
pool, post_id, row, vision_client
)
+ analysis_input_digest = operations_analysis_input_sha256(
+ evidence_sources, context
+ )
+ async with pool.acquire() as conn:
+ already_persisted = bool(
+ await conn.fetchval(
+ "select exists (select 1 from operations_case_analysis "
+ "where post_id = $1 and source_body_sha256 = $2 "
+ "and analysis_input_sha256 = $3)",
+ post_id,
+ source_body_digest,
+ analysis_input_digest,
+ )
+ )
+ if already_persisted:
+ return
+ case_client = ContextualOrchestratorOperationsCaseAnalysisClient(
+ orchestrator_base_url,
+ orchestrator_api_key,
+ )
cases = await asyncio.to_thread(case_client.analyze, evidence_sources, context)
async with pool.acquire() as conn:
await persist_operations_cases(
@@ -159,6 +165,7 @@ async def _persist_operations_case_analysis_if_needed(
raw_body,
session_id,
cases,
+ analysis_input_sha256=analysis_input_digest,
)
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 0876a94ab..bf0937b5b 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `aacef77deb378dde1f0c69c9947ff8c0fd0a1a30`. The pin remains explicit
+commit `d9c62be9feea24fdaeb8453f3c72f2c2b0237143`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
@@ -37,6 +37,8 @@ The runtime contract is:
endpoint; embedding-only rows are not added to the chat agent pool.
- A batch embedding request may omit `model`; contextual-orchestrator selects
an embedding-capable model and returns its identity for subsequent batches.
+- A blank embedding input fails before provider selection; it is never sent as
+ a successful empty semantic signal.
- An explicit remote agent tagged `embedding` uses its provider-backed
embedding transport rather than a local placeholder implementation.
- `json_object`, `json_schema`, and Responses JSON formats run conduct plus
diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
index 78876509a..3cb1f64f8 100644
--- a/docs/adr/0206-evidence-operations-dashboard.md
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -44,10 +44,13 @@ provenance.
regexes, provider-name ordering, local model selection, and hand-authored
scoring weights are prohibited.
5. Persist the result in normalized post case-analysis tables with the source
- body digest and orchestrator session/run provenance. A changed source body
- invalidates the old result and queues re-analysis through the existing
- content-ingestion lifecycle. Schema-invalid or unavailable results fail the
- job and remain retryable; they are not converted into a negative case.
+ body digest, a SHA-256 fingerprint of the exact ordered authorized evidence
+ window and context, and orchestrator session/run provenance. A changed
+ source body or input fingerprint invalidates reuse and queues re-analysis
+ through the existing content-ingestion lifecycle. Historical rows without
+ an input fingerprint are honest unknowns and re-analyze when next queued.
+ Schema-invalid or unavailable results fail the job and remain retryable;
+ they are not converted into a negative case.
6. External-information coverage is the distinct count of visible posts with
a persisted positive `external_information` classification divided by all
visible posts in the same period. The stored `vom` source code is supplied
@@ -179,7 +182,8 @@ treated as a negative case.
## Verification
- Parser and persistence tests cover multi-label output, cited spans, malformed
- responses, source-digest invalidation, and unavailable orchestrator states.
+ responses, source-body and ordered evidence-window invalidation, replay-safe
+ fingerprint storage, and unavailable orchestrator states.
- Backend integration tests cover ABAC filtering, event-time fallback, event
versus post counts, external-information percentage, multi-project
membership, explicit missing facts, observed lifecycle endpoints, exact
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index b2c1c612e..2042cb85c 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -28,7 +28,7 @@ containers while preserving named volumes.
| Apple-Silicon mathematical acceleration | ADR 0226 macOS-native Rust owner service with authenticated MLX Metal execution receipts | Normative boundary accepted; TEPP, fast-mlsirm, and RankWeave owner implementations and actual Metal parity receipts remain required before activation |
| Ask answer citation-to-event navigation | ADR 0225; authorized cited source, observed source clock, focused evidence layer, and full-post navigation | Stacked candidate renders numbered answer citations and chronologically ordered evidence cards with bidirectional focus. Event and record clocks stay distinct; this list does not claim a Project Journey. Storybook and component interaction evidence are included; authenticated runtime screenshots remain required at the exact candidate head. |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
-| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback. Post-scoped evidence collection follows exact persisted `project_key` membership, and newly analyzed project evidence durably requeues completed sibling analyses that still have missing facts. Focused backend tests pass; authenticated exact-head runtime acceptance remains pending. |
+| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback. Post-scoped evidence collection follows exact persisted `project_key` membership, and newly analyzed project evidence durably requeues completed sibling analyses that still have missing facts. Case-analysis reuse is bound to the exact ordered authorized evidence window and context, so the unchanged focal record re-analyzes when that window changes. Focused backend and replay-safe schema tests pass; authenticated exact-head runtime acceptance remains pending. |
| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending |
| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet |
| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open at `063f10f3`; stacked #251–#254 provide fail-closed input validation, full joint precision, deterministic joint plausible-value draws, and the canonical research register, while complete provenance assembly remains gated. fast-mlsirm PR #1418 validates the Rust consumer envelope but intentionally returns `EstimatorUnavailable` until the scientific estimator lands. Runtime therefore remains honestly unavailable with no local Python or fallback score. |
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py
index afd13beae..96df33bf1 100644
--- a/lineageweave/operations_case_analysis.py
+++ b/lineageweave/operations_case_analysis.py
@@ -121,6 +121,27 @@ def input_sha256(self) -> str:
return hashlib.sha256(self.text.encode("utf-8")).hexdigest()
+def operations_analysis_input_sha256(
+ sources: tuple[OperationsEvidenceSource, ...], context: str
+) -> str:
+ """Digest the exact ordered source window and context sent for analysis."""
+ payload = {
+ "context": context,
+ "sources": [
+ {
+ "post_id": source.post_id,
+ "title": source.title,
+ "input_sha256": source.input_sha256,
+ }
+ for source in sources
+ ],
+ }
+ encoded = json.dumps(
+ payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
class OperationsCaseAnalysisClient(Protocol):
"""Classify operational cases without keyword rules."""
diff --git a/migrations/0222_operations_case_analysis_input.sql b/migrations/0222_operations_case_analysis_input.sql
new file mode 100644
index 000000000..ad282fde2
--- /dev/null
+++ b/migrations/0222_operations_case_analysis_input.sql
@@ -0,0 +1,11 @@
+-- ADR 0206: bind operational case reuse to the exact authorized input window.
+alter table operations_case_analysis
+ add column if not exists analysis_input_sha256 text;
+
+alter table operations_case_analysis
+ drop constraint if exists operations_case_analysis_input_digest_check,
+ add constraint operations_case_analysis_input_digest_check
+ check (
+ analysis_input_sha256 is null
+ or analysis_input_sha256 ~ '^[0-9a-f]{64}$'
+ );
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index 864827418..314f21bf5 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "aacef77deb378dde1f0c69c9947ff8c0fd0a1a30"
+ expected_embedding_contract_commit = "d9c62be9feea24fdaeb8453f3c72f2c2b0237143"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py
index b9e744477..8f4d6e9e7 100644
--- a/tests/test_operations_case_analysis.py
+++ b/tests/test_operations_case_analysis.py
@@ -6,6 +6,7 @@
from lineageweave.operations_case_analysis import (
ContextualOrchestratorOperationsCaseAnalysisClient,
OperationsEvidenceSource,
+ operations_analysis_input_sha256,
parse_operations_case_response,
)
@@ -28,6 +29,25 @@ def post_json(_url, payload, **_kwargs):
assert captured["model"] == "orchestrator/auto"
+def test_analysis_input_digest_tracks_ordered_evidence_and_context() -> None:
+ """Cache identity changes when any orchestrator input changes."""
+ first = OperationsEvidenceSource("post-1", "First", "Evidence one")
+ second = OperationsEvidenceSource("post-2", "Second", "Evidence two")
+
+ baseline = operations_analysis_input_sha256((first, second), "project=P-1")
+
+ assert len(baseline) == 64
+ assert baseline == operations_analysis_input_sha256(
+ (first, second), "project=P-1"
+ )
+ assert baseline != operations_analysis_input_sha256(
+ (second, first), "project=P-1"
+ )
+ assert baseline != operations_analysis_input_sha256(
+ (first, second), "project=P-2"
+ )
+
+
def test_parses_multiple_cases_and_grounded_facts() -> None:
"""One record may support multiple case kinds without losing evidence."""
body = "The revised specification caused the claim. Mina agreed with Alex to rebid."
diff --git a/tests/test_operations_case_ingestion.py b/tests/test_operations_case_ingestion.py
index f24b1f3eb..e540996bf 100644
--- a/tests/test_operations_case_ingestion.py
+++ b/tests/test_operations_case_ingestion.py
@@ -51,9 +51,13 @@ def test_digest_and_atomic_normalized_persistence() -> None:
digest,
),
)
- asyncio.run(persist_operations_cases(conn, "post-1", "source", "session-1", cases))
+ asyncio.run(persist_operations_cases(
+ conn, "post-1", "source", "session-1", cases,
+ analysis_input_sha256="b" * 64,
+ ))
assert len(source_body_digest("source")) == 64
assert "delete from operations_case_analysis" in conn.calls[0][0]
+ assert conn.calls[1][1][-1] == "b" * 64
assert conn.batches == [
[("post-1", "claim_investigation", 0, "order", "A-1", "source", "post-1", digest, None)]
]
@@ -62,7 +66,10 @@ def test_digest_and_atomic_normalized_persistence() -> None:
def test_persists_supported_empty_analysis() -> None:
"""A completed no-case result is recorded without fabricated children."""
conn = _Connection()
- asyncio.run(persist_operations_cases(conn, "post-1", "ordinary", "session-1", ()))
+ asyncio.run(persist_operations_cases(
+ conn, "post-1", "ordinary", "session-1", (),
+ analysis_input_sha256="b" * 64,
+ ))
assert len(conn.calls) == 2
assert conn.batches == []
@@ -81,7 +88,10 @@ def test_persists_missing_required_facts_without_invented_evidence() -> None:
)
asyncio.run(
- persist_operations_cases(conn, "post-1", "source", "session-1", (case,))
+ persist_operations_cases(
+ conn, "post-1", "source", "session-1", (case,),
+ analysis_input_sha256="b" * 64,
+ )
)
assert conn.batches == [
@@ -120,7 +130,10 @@ def test_persists_observed_and_missing_milestones_separately() -> None:
)
asyncio.run(
- persist_operations_cases(conn, "post-1", "source", "session-1", (case,))
+ persist_operations_cases(
+ conn, "post-1", "source", "session-1", (case,),
+ analysis_input_sha256="b" * 64,
+ )
)
assert conn.batches[-2] == [
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index bb2549937..6b0fe4743 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -365,9 +365,16 @@ async def evidence_sources(*_args, **_kwargs):
def test_existing_case_analysis_skips_duplicate_orchestrator_call(monkeypatch) -> None:
- """A retry preserves same-digest analysis instead of spending another call."""
+ """A retry preserves the same exact input without another provider call."""
connection = _Connection(values=[True])
called: list[str] = []
+
+ async def evidence_sources(*_args, **_kwargs):
+ return (OperationsEvidenceSource("post-1", "Synthetic", "Evidence"),)
+
+ monkeypatch.setattr(
+ post_content_worker, "_operations_evidence_sources", evidence_sources
+ )
monkeypatch.setattr(
post_content_worker,
"ContextualOrchestratorOperationsCaseAnalysisClient",
@@ -391,6 +398,51 @@ def test_existing_case_analysis_skips_duplicate_orchestrator_call(monkeypatch) -
assert called == []
+def test_changed_evidence_window_reanalyzes_unchanged_body(monkeypatch) -> None:
+ """A newly available sibling invalidates reuse without changing focal text."""
+ connection = _Connection(values=[False])
+ analyzed: list[tuple[OperationsEvidenceSource, ...]] = []
+ persisted: list[str] = []
+
+ async def evidence_sources(*_args, **_kwargs):
+ return (
+ OperationsEvidenceSource("post-1", "Focal", "Focal evidence"),
+ OperationsEvidenceSource("post-2", "Sibling", "New sibling evidence"),
+ )
+
+ async def persist(*_args, **kwargs):
+ persisted.append(str(kwargs["analysis_input_sha256"]))
+
+ monkeypatch.setattr(
+ post_content_worker, "_operations_evidence_sources", evidence_sources
+ )
+ monkeypatch.setattr(
+ post_content_worker,
+ "ContextualOrchestratorOperationsCaseAnalysisClient",
+ lambda *_args: SimpleNamespace(
+ analyze=lambda sources, _context: analyzed.append(sources) or ()
+ ),
+ )
+ monkeypatch.setattr(post_content_worker, "persist_operations_cases", persist)
+
+ asyncio.run(
+ post_content_worker._persist_operations_case_analysis_if_needed(
+ _Pool(connection),
+ "00000000-0000-0000-0000-000000000001",
+ "a" * 64,
+ "Synthetic source body",
+ _row(RUNNING, 1),
+ SimpleNamespace(available=True),
+ "synthetic-session",
+ "gateway",
+ "key",
+ )
+ )
+
+ assert [source.post_id for source in analyzed[0]] == ["post-1", "post-2"]
+ assert len(persisted[0]) == 64
+
+
def test_sibling_requeue_failure_preserves_completed_primary_job(monkeypatch) -> None:
"""Ancillary retry discovery cannot fail already-persisted post evidence."""
outcomes: list[str] = []
@@ -465,7 +517,7 @@ async def evidence_sources(*_args, **_kwargs):
),
)
- async def persist_cases(_conn, _post_id, *_args):
+ async def persist_cases(_conn, _post_id, *_args, **_kwargs):
persisted.append("cases")
monkeypatch.setattr(post_content_worker, "_claim_job", claim)
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 62040411b..16ad44983 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -117,6 +117,11 @@
/ "migrations"
/ "0216_validate_operations_case_constraints.sql"
)
+_OPERATIONS_CASE_INPUT_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0222_operations_case_analysis_input.sql"
+)
_ANALYSIS_RUN_REGISTRY_MIGRATION = (
Path(__file__).resolve().parents[1] / "migrations" / "0018_analysis_run_registry.sql"
)
@@ -191,6 +196,7 @@ def schema_db():
cur.execute(_OPERATIONS_EXTERNAL_RELATION_MIGRATION.read_text())
cur.execute(_OPERATIONS_CASE_MILESTONE_MIGRATION.read_text())
cur.execute(_OPERATIONS_CASE_CONSTRAINT_VALIDATION_MIGRATION.read_text())
+ cur.execute(_OPERATIONS_CASE_INPUT_MIGRATION.read_text())
conn.commit()
yield conn
finally:
@@ -280,6 +286,24 @@ def test_operations_case_constraints_are_validated(schema_db) -> None:
assert constraints == {name: True for name in names}
+def test_operations_case_input_fingerprint_rejects_malformed_digest(schema_db) -> None:
+ """The input fingerprint is nullable only for honest historical unknowns."""
+ with schema_db.cursor() as cur:
+ cur.execute(
+ "select is_nullable from information_schema.columns "
+ "where table_name = 'operations_case_analysis' "
+ "and column_name = 'analysis_input_sha256'"
+ )
+ assert cur.fetchone() == ("YES",)
+ cur.execute(
+ "select pg_get_constraintdef(oid) from pg_constraint "
+ "where conname = 'operations_case_analysis_input_digest_check'"
+ )
+ definition = cur.fetchone()[0]
+ assert "analysis_input_sha256 IS NULL" in definition
+ assert "^[0-9a-f]{64}$" in definition
+
+
def test_operations_case_milestones_reject_cross_kind_types(schema_db) -> None:
"""Observed and missing endpoints accept only the activity set for their case."""
post_id = uuid.uuid4()
From 1cdb23e8fa50a01596fde6141ed74774d8f601a1 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:17:24 +0900
Subject: [PATCH 114/393] feat(embedding): add atomic cross-post bulk backfill
---
lineageweave/embedding_backfill.py | 148 +++++++++++++++++++++++++++
lineageweave/embedding_client.py | 19 +++-
scripts/backfill_post_embeddings.py | 61 +++++++++++
tests/test_embedding_backfill.py | 137 +++++++++++++++++++++++++
tests/test_embedding_client.py | 35 +++++++
tests/test_embedding_client_edges.py | 12 ++-
6 files changed, 409 insertions(+), 3 deletions(-)
create mode 100644 lineageweave/embedding_backfill.py
create mode 100755 scripts/backfill_post_embeddings.py
create mode 100644 tests/test_embedding_backfill.py
diff --git a/lineageweave/embedding_backfill.py b/lineageweave/embedding_backfill.py
new file mode 100644
index 000000000..0e4bc7283
--- /dev/null
+++ b/lineageweave/embedding_backfill.py
@@ -0,0 +1,148 @@
+"""Atomic, cross-post embedding backfill for already-normalized semantic units."""
+
+from __future__ import annotations
+
+import asyncio
+import math
+from typing import Any
+
+from .embedding_client import ContextualOrchestratorEmbeddingClient
+from .llm_context import build_post_llm_metadata
+
+_SELECT_UNITS_SQL = """
+select unit.post_content_unit_id, unit.unit_text, unit.unit_index,
+ post.post_id, post.author_account_id, post.source_process_unit_code,
+ post.source_author_code, post.source_company_code,
+ post.source_customer_code, post.source_project_code,
+ post.source_sales_pool_code, entity.corporate_entity_code
+ from post_content_unit unit
+ join source_post post using (post_id)
+ left join corporate_entity entity using (corporate_entity_id)
+ where nullif(btrim(unit.unit_text), '') is not null
+ and not exists (
+ select 1 from post_content_embedding existing
+ where existing.post_content_unit_id = unit.post_content_unit_id
+ )
+ order by post.created_at, post.post_id, unit.unit_index
+ limit $1
+"""
+
+
+async def backfill_post_content_embeddings(
+ conn: Any,
+ embedding_client: ContextualOrchestratorEmbeddingClient,
+ *,
+ input_limit: int,
+) -> dict[str, int | str]:
+ """Embed one explicitly bounded unit set and atomically persist the complete batch.
+
+ The provider call finishes and validates every vector before the transaction
+ starts. Consequently a provider failure cannot delete or partially replace a
+ persisted embedding. ``input_limit`` is an operator-supplied work selection,
+ not a locally invented provider limit; contextual-orchestrator remains the
+ owner of provider request partitioning.
+ """
+ if input_limit < 1:
+ raise ValueError("input_limit must be positive")
+ rows = list(await conn.fetch(_SELECT_UNITS_SQL, input_limit))
+ if not rows:
+ return {"selected_units": 0, "persisted_units": 0, "dimension_values": 0}
+
+ texts = [str(row["unit_text"]) for row in rows]
+ metadata = []
+ attributions = []
+ for row in rows:
+ item_metadata = build_post_llm_metadata(str(row["post_id"]), row)
+ item_metadata["lineageweave_post_content_unit_id"] = str(
+ row["post_content_unit_id"]
+ )
+ item_metadata["lineageweave_unit_index"] = str(row["unit_index"])
+ metadata.append(item_metadata)
+ attributions.append(
+ {
+ "service": "lineageweave",
+ **(
+ {"team": str(row["source_process_unit_code"])}
+ if row["source_process_unit_code"]
+ else {}
+ ),
+ **(
+ {"company": str(row["corporate_entity_code"])}
+ if row["corporate_entity_code"]
+ else {}
+ ),
+ }
+ )
+
+ vectors = await asyncio.to_thread(
+ embedding_client.embed_many,
+ texts,
+ input_attributions=attributions,
+ input_metadata=metadata,
+ )
+ if len(vectors) != len(rows):
+ raise ValueError("embedding batch did not return one vector per input")
+ dimension_count = len(vectors[0]) if vectors else 0
+ if dimension_count < 1 or any(
+ len(vector) != dimension_count
+ or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in vector)
+ for vector in vectors
+ ):
+ raise ValueError("embedding batch returned inconsistent vectors")
+ model = embedding_client.resolved_model
+ if not model:
+ raise ValueError("embedding batch did not identify its resolved model")
+
+ unit_ids = [row["post_content_unit_id"] for row in rows]
+ async with conn.transaction():
+ await conn.executemany(
+ """
+ insert into post_content_embedding
+ (post_content_unit_id, embedding_model_code, embedding_dimension_count)
+ values ($1, $2, $3)
+ on conflict (post_content_unit_id, embedding_model_code) do update
+ set embedding_dimension_count = excluded.embedding_dimension_count,
+ created_at = now()
+ """,
+ [(unit_id, model, dimension_count) for unit_id in unit_ids],
+ )
+ embedding_rows = await conn.fetch(
+ """
+ select post_content_embedding_id, post_content_unit_id
+ from post_content_embedding
+ where embedding_model_code = $1
+ and post_content_unit_id = any($2::uuid[])
+ """,
+ model,
+ unit_ids,
+ )
+ embedding_by_unit = {
+ row["post_content_unit_id"]: row["post_content_embedding_id"]
+ for row in embedding_rows
+ }
+ if len(embedding_by_unit) != len(unit_ids):
+ raise RuntimeError("embedding headers were not persisted completely")
+ embedding_ids = [embedding_by_unit[unit_id] for unit_id in unit_ids]
+ await conn.execute(
+ "delete from post_content_embedding_value where post_content_embedding_id = any($1::uuid[])",
+ embedding_ids,
+ )
+ values = [
+ (embedding_by_unit[unit_id], dimension_index, float(dimension_value))
+ for unit_id, vector in zip(unit_ids, vectors, strict=True)
+ for dimension_index, dimension_value in enumerate(vector)
+ ]
+ await conn.executemany(
+ """
+ insert into post_content_embedding_value
+ (post_content_embedding_id, dimension_index, dimension_value)
+ values ($1, $2, $3)
+ """,
+ values,
+ )
+ return {
+ "selected_units": len(rows),
+ "persisted_units": len(rows),
+ "dimension_values": len(rows) * dimension_count,
+ "model": model,
+ }
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index 3cb4d5975..770c142ed 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -12,6 +12,7 @@
import math
import time
+from collections.abc import Mapping
from typing import Protocol
from .chunking import Chunk, chunk_by_paragraph
@@ -86,16 +87,30 @@ def embed(self, text: str) -> list[float]:
"""Return an embedding for the supplied text."""
return self.embed_many([text])[0]
- def embed_many(self, texts: list[str]) -> list[list[float]]:
- """Return embeddings for the supplied texts."""
+ def embed_many(
+ self,
+ texts: list[str],
+ *,
+ input_attributions: list[Mapping[str, object]] | None = None,
+ input_metadata: list[Mapping[str, object]] | None = None,
+ ) -> list[list[float]]:
+ """Return embeddings while preserving optional per-input provenance."""
if not texts:
return []
+ if input_attributions is not None and len(input_attributions) != len(texts):
+ raise ValueError("input_attributions must align with texts")
+ if input_metadata is not None and len(input_metadata) != len(texts):
+ raise ValueError("input_metadata must align with texts")
headers = {"authorization": f"Bearer {self._api_key}"}
payload = {
"inputs": texts,
"endpoint": "/v1/embeddings",
"metadata": {"service": "lineageweave", "channel": "post_content_embedding"},
}
+ if input_attributions is not None:
+ payload["input_attributions"] = [dict(value) for value in input_attributions]
+ if input_metadata is not None:
+ payload["input_metadata"] = [dict(value) for value in input_metadata]
if self._model is not None:
payload["model"] = self._model
response = post_json(
diff --git a/scripts/backfill_post_embeddings.py b/scripts/backfill_post_embeddings.py
new file mode 100755
index 000000000..48a36bad8
--- /dev/null
+++ b/scripts/backfill_post_embeddings.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""Bulk-embed existing semantic units without rebuilding or deleting their source rows."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import os
+import sys
+from pathlib import Path
+
+import asyncpg
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
+if str(REPOSITORY_ROOT) not in sys.path:
+ sys.path.insert(0, str(REPOSITORY_ROOT))
+
+from lineageweave.embedding_backfill import backfill_post_content_embeddings
+from lineageweave.embedding_client import orchestrator_embedding_client
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--limit", required=True, type=int)
+ parser.add_argument(
+ "--target-dsn",
+ default=os.environ.get(
+ "DATABASE_URL",
+ "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave",
+ ),
+ )
+ return parser
+
+
+async def _run(target_dsn: str, input_limit: int) -> dict[str, int | str]:
+ client = orchestrator_embedding_client(
+ os.environ.get("ORCHESTRATOR_BASE_URL", ""),
+ os.environ.get("ORCHESTRATOR_API_KEY", ""),
+ )
+ if not client.available:
+ raise RuntimeError("embedding is unavailable; configure contextual-orchestrator")
+ conn = await asyncpg.connect(target_dsn)
+ try:
+ return await backfill_post_content_embeddings(
+ conn, client, input_limit=input_limit
+ )
+ finally:
+ await conn.close()
+
+
+def main() -> None:
+ """Run one operator-bounded embedding batch and print aggregate counts only."""
+ args = _parser().parse_args()
+ if args.limit < 1:
+ raise SystemExit("--limit must be positive")
+ print(json.dumps(asyncio.run(_run(args.target_dsn, args.limit)), sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_embedding_backfill.py b/tests/test_embedding_backfill.py
new file mode 100644
index 000000000..01f574e91
--- /dev/null
+++ b/tests/test_embedding_backfill.py
@@ -0,0 +1,137 @@
+"""Atomic bulk embedding backfill tests with synthetic records."""
+
+from __future__ import annotations
+
+import asyncio
+import uuid
+
+import pytest
+
+from lineageweave.embedding_backfill import backfill_post_content_embeddings
+
+
+class _Transaction:
+ def __init__(self, conn):
+ self.conn = conn
+
+ async def __aenter__(self):
+ self.conn.transaction_entries += 1
+
+ async def __aexit__(self, exc_type, exc, traceback):
+ return False
+
+
+class _Connection:
+ def __init__(self, rows):
+ self.rows = rows
+ self.executemany_calls = []
+ self.execute_calls = []
+ self.transaction_entries = 0
+ self.embedding_ids = {
+ row["post_content_unit_id"]: uuid.uuid4() for row in rows
+ }
+
+ async def fetch(self, query, *args):
+ if "from post_content_unit unit" in query:
+ return self.rows
+ return [
+ {
+ "post_content_unit_id": unit_id,
+ "post_content_embedding_id": embedding_id,
+ }
+ for unit_id, embedding_id in self.embedding_ids.items()
+ ]
+
+ def transaction(self):
+ return _Transaction(self)
+
+ async def executemany(self, query, args):
+ self.executemany_calls.append((query, list(args)))
+
+ async def execute(self, query, *args):
+ self.execute_calls.append((query, args))
+
+
+class _EmbeddingClient:
+ available = True
+
+ def __init__(self, *, fail=False):
+ self.fail = fail
+ self.resolved_model = None
+ self.calls = []
+
+ def embed_many(self, texts, **kwargs):
+ self.calls.append((list(texts), kwargs))
+ if self.fail:
+ raise RuntimeError("synthetic provider failure")
+ self.resolved_model = "synthetic-embedding-model"
+ return [[float(index), 1.0] for index, _text in enumerate(texts)]
+
+
+def _row(index: int) -> dict[str, object]:
+ return {
+ "post_content_unit_id": uuid.uuid4(),
+ "unit_text": f"synthetic semantic unit {index}",
+ "unit_index": index,
+ "post_id": uuid.uuid4(),
+ "author_account_id": f"synthetic-author-{index}",
+ "source_process_unit_code": f"synthetic-team-{index}",
+ "source_author_code": None,
+ "source_company_code": None,
+ "source_customer_code": None,
+ "source_project_code": None,
+ "source_sales_pool_code": None,
+ "corporate_entity_code": f"synthetic-company-{index}",
+ }
+
+
+def test_bulk_backfill_calls_provider_once_and_persists_in_one_transaction() -> None:
+ rows = [_row(0), _row(1)]
+ conn = _Connection(rows)
+ client = _EmbeddingClient()
+
+ result = asyncio.run(backfill_post_content_embeddings(conn, client, input_limit=2))
+
+ assert result == {
+ "selected_units": 2,
+ "persisted_units": 2,
+ "dimension_values": 4,
+ "model": "synthetic-embedding-model",
+ }
+ assert len(client.calls) == 1
+ assert len(client.calls[0][0]) == 2
+ assert [item["team"] for item in client.calls[0][1]["input_attributions"]] == [
+ "synthetic-team-0",
+ "synthetic-team-1",
+ ]
+ assert len(client.calls[0][1]["input_metadata"]) == 2
+ assert conn.transaction_entries == 1
+ assert len(conn.executemany_calls) == 2
+ assert len(conn.executemany_calls[1][1]) == 4
+
+
+def test_provider_failure_makes_no_database_change() -> None:
+ conn = _Connection([_row(0), _row(1)])
+ client = _EmbeddingClient(fail=True)
+
+ with pytest.raises(RuntimeError, match="synthetic provider failure"):
+ asyncio.run(backfill_post_content_embeddings(conn, client, input_limit=2))
+
+ assert conn.transaction_entries == 0
+ assert conn.executemany_calls == []
+ assert conn.execute_calls == []
+
+
+def test_empty_selection_skips_provider_and_transaction() -> None:
+ conn = _Connection([])
+ client = _EmbeddingClient()
+
+ result = asyncio.run(backfill_post_content_embeddings(conn, client, input_limit=1))
+
+ assert result == {
+ "selected_units": 0,
+ "persisted_units": 0,
+ "dimension_values": 0,
+ }
+ assert client.calls == []
+ assert conn.transaction_entries == 0
diff --git a/tests/test_embedding_client.py b/tests/test_embedding_client.py
index 8a424a4e4..a604e5330 100644
--- a/tests/test_embedding_client.py
+++ b/tests/test_embedding_client.py
@@ -123,3 +123,38 @@ def fake_get_json(url, *, headers, timeout, service_peer_name):
assert client.embed_many(["third", "fourth"]) == [[0.0, 1.0], [2.0, 3.0]]
assert calls[2][2]["model"] == "resolved-embedding"
+
+
+def test_orchestrator_embedding_client_submits_index_aligned_provenance(monkeypatch) -> None:
+ """Each bulk input carries its own source metadata and cost attribution."""
+ captured = {}
+
+ def fake_post_json(url, payload, *, headers, timeout):
+ captured.update(payload)
+ return {
+ "status": "completed",
+ "model": "resolved-embedding",
+ "embeddings": [
+ {"index": 0, "embedding": [1.0]},
+ {"index": 1, "embedding": [2.0]},
+ ],
+ }
+
+ monkeypatch.setattr("lineageweave.embedding_client.post_json", fake_post_json)
+ client = ContextualOrchestratorEmbeddingClient(
+ "http://orchestrator:8000", "synthetic-token"
+ )
+
+ assert client.embed_many(
+ ["first", "second"],
+ input_attributions=[{"team": "alpha"}, {"team": "beta"}],
+ input_metadata=[{"session_id": "one"}, {"session_id": "two"}],
+ ) == [[1.0], [2.0]]
+ assert captured["input_attributions"] == [
+ {"team": "alpha"},
+ {"team": "beta"},
+ ]
+ assert captured["input_metadata"] == [
+ {"session_id": "one"},
+ {"session_id": "two"},
+ ]
diff --git a/tests/test_embedding_client_edges.py b/tests/test_embedding_client_edges.py
index 1c6839781..2fa205429 100644
--- a/tests/test_embedding_client_edges.py
+++ b/tests/test_embedding_client_edges.py
@@ -2,7 +2,7 @@
import pytest
-import lineageweave.embedding_client as embedding_client
+from lineageweave import embedding_client
def test_missing_embedding_configuration_returns_null_client() -> None:
@@ -19,6 +19,16 @@ def test_empty_batch_does_not_call_orchestrator(monkeypatch: pytest.MonkeyPatch)
assert client.embed_many([]) == []
+@pytest.mark.parametrize("field", ["input_attributions", "input_metadata"])
+def test_per_input_context_must_align_with_texts(field: str) -> None:
+ client = embedding_client.ContextualOrchestratorEmbeddingClient(
+ "http://orchestrator", "key", "model"
+ )
+
+ with pytest.raises(ValueError, match=field):
+ client.embed_many(["first", "second"], **{field: [{"key": "value"}]})
+
+
def test_immediate_embedding_response_is_ordered(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
embedding_client,
From f07f7135e819df5466aee6f7fb65f551ba91a4be Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:20:46 +0900
Subject: [PATCH 115/393] build(backend): include embedding backfill operator
---
backend/Dockerfile | 1 +
1 file changed, 1 insertion(+)
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 14851ad43..9230812d3 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -28,6 +28,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --extra backend --no-install-project --no-editable
COPY lineageweave ./lineageweave
COPY backend ./backend
+COPY scripts/backfill_post_embeddings.py ./scripts/backfill_post_embeddings.py
# lineageweave/ontology.py resolves this path relative to itself
# (parents[1] = /app) -- ADR 0004.
COPY docs/ontology ./docs/ontology
From b597b0182084a89222d1a04d05878adebfa9ec2b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 21:26:19 -0700
Subject: [PATCH 116/393] fix(ask): find related source evidence automatically
(#688)
* fix(ask): find related source evidence automatically
* fix(ask): preserve source publication boundary
* fix(ask): keep project evidence out of event lineage
* test(ask): lock graph and project boundaries
* fix(ask): preserve project evidence in dense windows
* fix(orchestrator): register remote embedding agent
* build(orchestrator): pin provider embedding runtime
* docs(orchestrator): separate embedding bootstrap boundary
* fix(ui): keep missing-evidence guidance accurate
* fix(ui): explain automatic evidence retry
* fix(operations): reanalyze missing facts on new evidence
* fix(runtime): restore semantic backfill contracts
* fix(ask): keep project evidence first
* fix(worker): persist operational cases before optional content enrichment
* fix(worker): preserve completed evidence on sibling retry outage
* fix(operations): use orchestrator auto model selector
* fix(compose): install orchestrator runtime dependency
* perf: bulk semantic embedding backfill
* fix(operations): address orchestrator auto deployment
* perf: prepare semantic backfill batch concurrently
* fix: isolate operations analysis from embedding batch
* Revert "fix: isolate operations analysis from embedding batch"
This reverts commit a7cda9b3434fa75a3b49aca541fb5a6a670c229b.
* Revert "perf: prepare semantic backfill batch concurrently"
This reverts commit 1d16c402d661f488801dddd648dd18f996344358.
* Revert "perf: bulk semantic embedding backfill"
This reverts commit 891e0f9021dfd71a8b0455a9e4d627b8cd41b1fb.
* fix(runtime): pin bounded provider failover
* fix(operations): invalidate stale evidence windows
* feat(embedding): add atomic cross-post bulk backfill
* build(backend): include embedding backfill operator
---------
Co-authored-by: Codex
---
CHANGELOG.md | 10 +
backend/Dockerfile | 1 +
backend/app/operations_case_ingestion.py | 5 +-
backend/app/post_chat_ingestion.py | 38 ++-
backend/app/post_content_worker.py | 171 +++++++++---
backend/tests/test_api.py | 10 +-
docker/contextual-orchestrator/Dockerfile | 3 +-
docker/contextual-orchestrator/start.py | 16 +-
.../0030-external-llm-gateway-environment.md | 17 +-
.../0083-orchestrator-runtime-commit-pin.md | 6 +-
.../adr/0206-evidence-operations-dashboard.md | 25 +-
docs/product-technical-gap-baseline.md | 9 +-
frontend/src/App.css | 47 ++++
frontend/src/App.tsx | 34 ++-
.../OperationsDashboard.stories.tsx | 4 +-
.../components/OperationsDashboard.test.tsx | 2 +-
.../src/components/OperationsDashboard.tsx | 6 +-
lineageweave/embedding_backfill.py | 148 ++++++++++
lineageweave/embedding_client.py | 19 +-
lineageweave/operations_case_analysis.py | 22 ++
.../0222_operations_case_analysis_input.sql | 11 +
scripts/backfill_post_embeddings.py | 61 +++++
tests/test_contextual_orchestrator_start.py | 28 +-
tests/test_documentation_hygiene.py | 12 +-
tests/test_embedding_backfill.py | 137 +++++++++
tests/test_embedding_client.py | 35 +++
tests/test_embedding_client_edges.py | 12 +-
tests/test_operations_case_analysis.py | 40 +++
tests/test_operations_case_ingestion.py | 21 +-
tests/test_post_chat_ingestion.py | 81 ++++++
tests/test_post_content_worker.py | 259 +++++++++++++++++-
tests/test_schema.py | 24 ++
32 files changed, 1220 insertions(+), 94 deletions(-)
create mode 100644 lineageweave/embedding_backfill.py
create mode 100644 migrations/0222_operations_case_analysis_input.sql
create mode 100755 scripts/backfill_post_embeddings.py
create mode 100644 tests/test_embedding_backfill.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9efed2cde..a8c301f56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,16 @@ All notable changes to this project are documented here. Format follows
### Changed
+- Ask Agent now searches posts with the same persisted semantic project key as
+ part of its authorized evidence window before re-analysis. Missing evidence
+ remains a retry state rather than a request for the reader to attach an
+ original; newly analyzed project evidence now requeues completed sibling
+ analyses that still have missing facts. Case-analysis reuse now binds to a
+ digest of the exact ordered authorized evidence window and context, so an
+ unchanged focal body is re-analyzed when new sibling evidence arrives. The
+ composer now uses an accessible form with a stable action and separate
+ progress status.
+
- Docker Compose now has one canonical `lineageweave` project containing the
complete synthetic product stack. The backend receives the existing TEPP API
credential contract, and the OIDC smoke target installs its declared backend
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 14851ad43..9230812d3 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -28,6 +28,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --extra backend --no-install-project --no-editable
COPY lineageweave ./lineageweave
COPY backend ./backend
+COPY scripts/backfill_post_embeddings.py ./scripts/backfill_post_embeddings.py
# lineageweave/ontology.py resolves this path relative to itself
# (parents[1] = /app) -- ADR 0004.
COPY docs/ontology ./docs/ontology
diff --git a/backend/app/operations_case_ingestion.py b/backend/app/operations_case_ingestion.py
index f6b1bbed8..becfc90ad 100644
--- a/backend/app/operations_case_ingestion.py
+++ b/backend/app/operations_case_ingestion.py
@@ -33,6 +33,8 @@ async def persist_operations_cases(
source_body: str,
orchestrator_session_id: str,
cases: tuple[OperationsCase, ...],
+ *,
+ analysis_input_sha256: str,
) -> None:
"""Atomically replace one post's normalized case analysis."""
async with conn.transaction():
@@ -40,10 +42,11 @@ async def persist_operations_cases(
"delete from operations_case_analysis where post_id = $1", post_id
)
await conn.execute(
- "insert into operations_case_analysis (post_id, source_body_sha256, orchestrator_session_id) values ($1, $2, $3)",
+ "insert into operations_case_analysis (post_id, source_body_sha256, orchestrator_session_id, analysis_input_sha256) values ($1, $2, $3, $4)",
post_id,
source_body_sha256(source_body),
orchestrator_session_id,
+ analysis_input_sha256,
)
for case in cases:
await conn.execute(
diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py
index 2777e8c66..5f72942ad 100644
--- a/backend/app/post_chat_ingestion.py
+++ b/backend/app/post_chat_ingestion.py
@@ -283,6 +283,30 @@ async def find_linked_post_ids(conn: asyncpg.Connection, post_id: str) -> Linked
return LinkedPostIds(direct=direct_ids - {post_id}, indirect=indirect_ids - direct_ids)
+async def find_project_sibling_post_ids(
+ conn: asyncpg.Connection, post_id: str
+) -> frozenset[str]:
+ """Published posts sharing a persisted project key, for Ask context only."""
+ project_rows = await conn.fetch(
+ "select distinct project_key from post_project_mention where post_id = $1",
+ post_id,
+ )
+ project_keys = [str(row["project_key"]) for row in project_rows]
+ if not project_keys:
+ return frozenset()
+ rows = await conn.fetch(
+ "select distinct ppm.post_id from post_project_mention ppm "
+ "join source_post sp on sp.post_id = ppm.post_id "
+ "where ppm.project_key = any($1::text[]) and ppm.post_id <> $2 "
+ f"and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='sp')} "
+ "order by ppm.post_id limit $3",
+ project_keys,
+ post_id,
+ _POST_CHAT_CANDIDATE_LIMIT,
+ )
+ return frozenset(str(row["post_id"]) for row in rows)
+
+
async def gather_chat_sources(
conn: asyncpg.Connection,
post_id: str,
@@ -291,9 +315,11 @@ async def gather_chat_sources(
) -> list[ChatSourceDocument]:
"""Post `post_id` plus a bounded, deterministic linked-source window.
- Direct Event Lineage neighbors precede indirect Knowledge Graph
- neighbors; both groups are identifier-sorted before ABAC filtering. The
- current post plus at most seven visible linked posts become the numbered
+ Persisted semantic-project siblings precede direct Event Lineage and
+ indirect Knowledge Graph neighbors; each group is identifier-sorted before
+ ABAC filtering. This gives exact project membership a bounded opportunity
+ to supply the missing original even when graph neighborhoods are dense.
+ The current post plus at most seven visible linked posts become the numbered
source set that `post_chat` citations refer back to. Every source's body
is normalized (HTML tags/base64 images never reach the reason-and-cite
LLM call raw) before becoming a `ChatSourceDocument` -- see
@@ -332,9 +358,11 @@ async def gather_chat_sources(
]
linked = await find_linked_post_ids(conn, post_id)
+ project_sibling_ids = await find_project_sibling_post_ids(conn, post_id)
candidate_ids = [
- *sorted(linked.direct),
- *sorted(linked.indirect),
+ *sorted(project_sibling_ids),
+ *sorted(linked.direct - project_sibling_ids),
+ *sorted(linked.indirect - project_sibling_ids),
][:_POST_CHAT_CANDIDATE_LIMIT]
if not candidate_ids:
return sources
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 5cf7407b0..daf46fee3 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -21,12 +21,16 @@
RUNNING,
STALE_RUNNING_INTERVAL,
SUCCEEDED,
+ ensure_post_content_job,
post_content_is_complete,
republish_queued_post_content_jobs,
transition_post_content_job,
)
from backend.app.operations_case_ingestion import persist_operations_cases
-from backend.app.post_chat_ingestion import gather_chat_sources
+from backend.app.post_chat_ingestion import (
+ find_project_sibling_post_ids,
+ gather_chat_sources,
+)
from lineageweave.embedding_client import EmbeddingClient
from lineageweave.http_client import HttpClientError
from lineageweave.image_content import ImageContentClient
@@ -35,6 +39,7 @@
from lineageweave.operations_case_analysis import (
ContextualOrchestratorOperationsCaseAnalysisClient,
OperationsEvidenceSource,
+ operations_analysis_input_sha256,
)
from lineageweave.post_content_normalization import normalize_post_body
from lineageweave.post_content_persistence import persist_post_content
@@ -106,6 +111,100 @@ def can_see(row: asyncpg.Record) -> bool:
)
+async def _persist_operations_case_analysis_if_needed(
+ pool: asyncpg.Pool,
+ post_id: str,
+ source_body_digest: str,
+ raw_body: str,
+ row: asyncpg.Record,
+ vision_client: ImageContentClient,
+ session_id: str,
+ orchestrator_base_url: str,
+ orchestrator_api_key: str,
+) -> None:
+ """Persist cases once per exact focal body and authorized evidence window."""
+ context = " | ".join(
+ f"{name}={row[name]}"
+ for name in (
+ "source_project_code",
+ "source_project_name",
+ "source_sales_pool_code",
+ "source_sales_pool_name",
+ "voc_type_code",
+ )
+ if row.get(name) is not None and str(row[name]).strip()
+ )
+ evidence_sources = await _operations_evidence_sources(
+ pool, post_id, row, vision_client
+ )
+ analysis_input_digest = operations_analysis_input_sha256(
+ evidence_sources, context
+ )
+ async with pool.acquire() as conn:
+ already_persisted = bool(
+ await conn.fetchval(
+ "select exists (select 1 from operations_case_analysis "
+ "where post_id = $1 and source_body_sha256 = $2 "
+ "and analysis_input_sha256 = $3)",
+ post_id,
+ source_body_digest,
+ analysis_input_digest,
+ )
+ )
+ if already_persisted:
+ return
+ case_client = ContextualOrchestratorOperationsCaseAnalysisClient(
+ orchestrator_base_url,
+ orchestrator_api_key,
+ )
+ cases = await asyncio.to_thread(case_client.analyze, evidence_sources, context)
+ async with pool.acquire() as conn:
+ await persist_operations_cases(
+ conn,
+ post_id,
+ raw_body,
+ session_id,
+ cases,
+ analysis_input_sha256=analysis_input_digest,
+ )
+
+
+async def _requeue_project_missing_case_jobs(
+ pool: asyncpg.Pool,
+ post_id: str,
+) -> int:
+ """Re-analyze older project siblings that still lack required facts."""
+ async with pool.acquire() as conn:
+ async with conn.transaction():
+ sibling_ids = await find_project_sibling_post_ids(conn, post_id)
+ if not sibling_ids:
+ return 0
+ rows = await conn.fetch(
+ """
+ select distinct post.post_id, post.post_body
+ from operations_case_missing_fact missing
+ join source_post post on post.post_id = missing.post_id
+ join post_content_ingestion_job job on job.post_id = missing.post_id
+ where missing.post_id = any($1::uuid[])
+ and job.status_code = $2
+ and nullif(btrim(post.post_body), '') is not null
+ order by post.post_id
+ """,
+ [UUID(sibling_id) for sibling_id in sibling_ids],
+ SUCCEEDED,
+ )
+ queued = 0
+ for row in rows:
+ request = await ensure_post_content_job(
+ conn,
+ str(row["post_id"]),
+ str(row["post_body"]),
+ content_complete=False,
+ )
+ queued += int(request.should_publish)
+ return queued
+
+
async def _stream_tail(client: redis.Redis) -> str:
"""Start after historical wake-ups; the normalized ledger drives recovery."""
with traced(
@@ -136,7 +235,12 @@ async def _claim_job(
j.status_code as job_status_code,
j.attempt_count as job_attempt_count,
j.started_at as job_started_at,
- j.queued_at as job_queued_at
+ j.queued_at as job_queued_at,
+ (
+ select analysis.source_body_sha256
+ from operations_case_analysis analysis
+ where analysis.post_id = p.post_id
+ ) as case_analysis_source_body_sha256
from post_content_ingestion_job j
join source_post p on p.post_id = j.post_id
where j.post_id = $1::uuid
@@ -172,7 +276,7 @@ async def _claim_job(
return None
if status_code == QUEUED and attempt_count > 0:
retry_ready = await conn.fetchval(
- "select now() >= $1 + $2::interval",
+ "select now() >= $1::timestamptz + $2::interval",
row["job_queued_at"],
POST_CONTENT_RETRY_INTERVAL,
)
@@ -197,7 +301,7 @@ async def _claim_job(
return None
if status_code == RUNNING and row["job_started_at"] is not None:
stale = await conn.fetchval(
- "select now() - $1 > $2::interval",
+ "select now() - $1::timestamptz > $2::interval",
row["job_started_at"],
STALE_RUNNING_INTERVAL,
)
@@ -335,6 +439,18 @@ async def process_post_content_job(
structure_client = structure_factory()
with use_llm_metadata(metadata):
vision_client = vision_factory()
+ if settings.orchestrator_base_url and settings.orchestrator_api_key:
+ await _persist_operations_case_analysis_if_needed(
+ pool,
+ post_id,
+ source_body_digest,
+ raw_body,
+ row,
+ vision_client,
+ metadata["lineageweave_post_session_id"],
+ settings.orchestrator_base_url,
+ settings.orchestrator_api_key,
+ )
normalized = await asyncio.to_thread(
normalize_post_body, raw_body, vision_client
)
@@ -349,38 +465,6 @@ async def process_post_content_job(
structure_client=structure_client,
post_title=str(row["post_title"]),
)
- if settings.orchestrator_base_url and settings.orchestrator_api_key:
- case_client = ContextualOrchestratorOperationsCaseAnalysisClient(
- settings.orchestrator_base_url,
- settings.orchestrator_api_key,
- )
- context = " | ".join(
- f"{name}={row[name]}"
- for name in (
- "source_project_code",
- "source_project_name",
- "source_sales_pool_code",
- "source_sales_pool_name",
- "voc_type_code",
- )
- if row.get(name) is not None and str(row[name]).strip()
- )
- evidence_sources = await _operations_evidence_sources(
- pool, post_id, row, vision_client
- )
- cases = await asyncio.to_thread(
- case_client.analyze,
- evidence_sources,
- context,
- )
- async with pool.acquire() as conn:
- await persist_operations_cases(
- conn,
- post_id,
- raw_body,
- metadata["lineageweave_post_session_id"],
- cases,
- )
async with pool.acquire() as conn:
complete = await post_content_is_complete(
conn,
@@ -400,6 +484,21 @@ async def process_post_content_job(
expected_attempt_count=attempt_count,
)
return
+ if (
+ settings.orchestrator_base_url
+ and settings.orchestrator_api_key
+ and row.get("case_analysis_source_body_sha256")
+ != source_body_digest
+ ):
+ try:
+ await _requeue_project_missing_case_jobs(pool, post_id)
+ except Exception as exc: # noqa: BLE001 - primary evidence is complete.
+ _logger.error("project sibling requeue failed for post_id=%s", post_id)
+ record_server_failure(
+ "post_content_sibling_requeue",
+ exc,
+ outcome="provider_unavailable",
+ )
except Exception as exc: # noqa: BLE001 - durable failure is recorded for retry.
_logger.error("post content ingestion failed for post_id=%s", post_id)
outcome = (
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 08f909f28..0d10021b8 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -1068,7 +1068,10 @@ def test_create_analysis_run_records_pending_without_inventing_a_score(
},
)
assert tepp.status_code == 422
- assert "invent a measurement" in tepp.json()["detail"]
+ assert (
+ tepp.json()["detail"]
+ == "Open the failed temporal measurement, ask an administrator to restore analysis, then re-run it."
+ )
assert "theta" not in tepp.json()["detail"].lower()
report = client.post(
@@ -1227,7 +1230,10 @@ def test_start_analysis_run_recovers_the_a100_fork(
},
)
assert tepp_create.status_code == 422
- assert "invent a measurement" in tepp_create.json()["detail"]
+ assert (
+ tepp_create.json()["detail"]
+ == "Open the failed temporal measurement, ask an administrator to restore analysis, then re-run it."
+ )
admin_conn = psycopg2.connect(seeded_db["dsn"])
admin_conn.autocommit = True
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 0af60f58c..d2ab83d29 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,13 +5,14 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/d9c62be9feea24fdaeb8453f3c72f2c2b0237143.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
&& cp -R /tmp/contextual-orchestrator/examples /app/examples \
&& rm -rf /tmp/contextual-orchestrator /tmp/contextual-orchestrator.tar.gz \
&& python -m pip install --no-cache-dir \
+ 'cryptography>=43.0' \
'opentelemetry-api>=1.30.0' \
'opentelemetry-sdk>=1.30.0' \
'opentelemetry-exporter-otlp-proto-http>=1.30.0' \
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 01dc5d189..07fa48cc1 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -48,6 +48,7 @@ def main() -> None:
raise SystemExit("LLM_GATEWAY_API_URL or LLM_GATEWAY_URL is required to start the gateway")
if not provider_url.rstrip("/").endswith("/v1"):
provider_url = provider_url.rstrip("/") + "/v1"
+ embedding_model = os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", "").strip()
raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
try:
max_output_tokens = int(raw_limit)
@@ -68,7 +69,18 @@ def main() -> None:
agent["base_url"] = provider_url
agent["credential_key"] = "LLM_GATEWAY_API_KEY"
agent.setdefault("provider_protocol", "auto")
- os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", None)
+ if embedding_model:
+ agents["agents"].append(
+ {
+ "id": "gateway_embedding_agent",
+ "model": embedding_model,
+ "provider_protocol": "auto",
+ "base_url": provider_url,
+ "credential_key": "LLM_GATEWAY_API_KEY",
+ "tags": ["embedding"],
+ "priority": 1,
+ }
+ )
agents_path.write_text(json.dumps(agents), encoding="utf-8")
from contextual_orchestrator.credentials import register_credential
@@ -84,7 +96,6 @@ def main() -> None:
"--agents",
str(agents_path),
"--auto-discover-model-agents",
- "--allow-discovery-failures",
"--host",
"0.0.0.0",
"--port",
@@ -98,6 +109,7 @@ def main() -> None:
str(max_body_bytes),
]
del provider_url
+ del embedding_model
del auth_token
from contextual_orchestrator.__main__ import main as serve
diff --git a/docs/adr/0030-external-llm-gateway-environment.md b/docs/adr/0030-external-llm-gateway-environment.md
index fccc1636f..58910df4b 100644
--- a/docs/adr/0030-external-llm-gateway-environment.md
+++ b/docs/adr/0030-external-llm-gateway-environment.md
@@ -75,12 +75,17 @@ must never be returned through a buyer-facing API or persisted failure detail.
When they are blank, contextual-orchestrator resolves the registered agent
model, so a local or provider-specific model name cannot leak into this
application or be assumed available on an external gateway.
-- LineageWeave does not configure an embedding model. Its first batch request
- omits `model`; contextual-orchestrator selects a provider-neutral embedding
- model and returns that identity on submission and polling responses.
- LineageWeave binds that identity for later batches and persists it with every
- vector. A missing or changed identity, or an incomplete vector batch, fails
- closed and cannot make post content complete.
+- LineageWeave embedding requests do not select a model: every batch omits
+ `model`. At the Compose process boundary an operator-supplied
+ `LLM_GATEWAY_EMBEDDING_MODEL` may register one explicit remote agent tagged
+ `embedding` in contextual-orchestrator, using the same provider URL and
+ credential handle as the gateway. The bootstrap removes that environment
+ value before serving; application code never reads it, sends it in a request,
+ or calls the provider directly. contextual-orchestrator returns the selected
+ identity on submission and polling responses. LineageWeave binds that
+ identity for later batches and persists it with every vector. A missing or
+ changed identity, or an incomplete vector batch, fails closed and cannot make
+ post content complete.
- `LLM_API_KEY`, `LLM_API_GATEWAY`, and `LLM_GATEWAY_URL` are compatibility
aliases only; `LLM_GATEWAY_API_KEY` and `LLM_GATEWAY_API_URL` are the
canonical names for
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 1ad14cade..bf0937b5b 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89`. The pin remains explicit
+commit `d9c62be9feea24fdaeb8453f3c72f2c2b0237143`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
@@ -37,6 +37,10 @@ The runtime contract is:
endpoint; embedding-only rows are not added to the chat agent pool.
- A batch embedding request may omit `model`; contextual-orchestrator selects
an embedding-capable model and returns its identity for subsequent batches.
+- A blank embedding input fails before provider selection; it is never sent as
+ a successful empty semantic signal.
+- An explicit remote agent tagged `embedding` uses its provider-backed
+ embedding transport rather than a local placeholder implementation.
- `json_object`, `json_schema`, and Responses JSON formats run conduct plus
synthesis. Tool requests never silently fall back to one agent.
diff --git a/docs/adr/0206-evidence-operations-dashboard.md b/docs/adr/0206-evidence-operations-dashboard.md
index 5e48ba6c2..3cb1f64f8 100644
--- a/docs/adr/0206-evidence-operations-dashboard.md
+++ b/docs/adr/0206-evidence-operations-dashboard.md
@@ -44,10 +44,13 @@ provenance.
regexes, provider-name ordering, local model selection, and hand-authored
scoring weights are prohibited.
5. Persist the result in normalized post case-analysis tables with the source
- body digest and orchestrator session/run provenance. A changed source body
- invalidates the old result and queues re-analysis through the existing
- content-ingestion lifecycle. Schema-invalid or unavailable results fail the
- job and remain retryable; they are not converted into a negative case.
+ body digest, a SHA-256 fingerprint of the exact ordered authorized evidence
+ window and context, and orchestrator session/run provenance. A changed
+ source body or input fingerprint invalidates reuse and queues re-analysis
+ through the existing content-ingestion lifecycle. Historical rows without
+ an input fingerprint are honest unknowns and re-analyze when next queued.
+ Schema-invalid or unavailable results fail the job and remain retryable;
+ they are not converted into a negative case.
6. External-information coverage is the distinct count of visible posts with
a persisted positive `external_information` classification divided by all
visible posts in the same period. The stored `vom` source code is supplied
@@ -63,7 +66,11 @@ provenance.
absent from the authorized corpus.
The analysis input reuses the post-chat source assembler: focal post first,
then bounded Event Lineage and semantic-neighborhood posts after the same
- corporate-entity/process-unit ABAC check. Every classification and fact
+ corporate-entity/process-unit ABAC check. The semantic window includes posts
+ carrying the same persisted `post_project_mention.project_key`; display-name
+ similarity and keyword matching do not create that link. This lookup applies
+ the shared source-post publication eligibility boundary and a deterministic
+ candidate limit before graph loading. Every classification and fact
persists its evidence post id and the SHA-256 of the exact numbered input
document. A span that does not occur in that identified document rejects
the whole provider response; linked evidence is never rewritten as focal
@@ -71,8 +78,9 @@ provenance.
8. Claim-investigation and rebid/handover panels include positively classified
cases and show extracted answers plus cited spans. A required answer that
the source does not support is stored in the normalized
- `operations_case_missing_fact` relation as an explicit missing fact, so the
- next action is collection or human correction rather than keyword guessing.
+ `operations_case_missing_fact` relation as an explicit retry state while the
+ system searches the authorized semantic source window and re-analyzes the
+ case. The reader is not asked to attach the source manually.
A provider result is invalid unless every required question is represented
exactly once as either a cited supported fact or an explicit missing fact;
a fact cannot be both. Missing facts carry no invented value or evidence
@@ -174,7 +182,8 @@ treated as a negative case.
## Verification
- Parser and persistence tests cover multi-label output, cited spans, malformed
- responses, source-digest invalidation, and unavailable orchestrator states.
+ responses, source-body and ordered evidence-window invalidation, replay-safe
+ fingerprint storage, and unavailable orchestrator states.
- Backend integration tests cover ABAC filtering, event-time fallback, event
versus post counts, external-information percentage, multi-project
membership, explicit missing facts, observed lifecycle endpoints, exact
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index f0d0d6215..2042cb85c 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -28,7 +28,7 @@ containers while preserving named volumes.
| Apple-Silicon mathematical acceleration | ADR 0226 macOS-native Rust owner service with authenticated MLX Metal execution receipts | Normative boundary accepted; TEPP, fast-mlsirm, and RankWeave owner implementations and actual Metal parity receipts remain required before activation |
| Ask answer citation-to-event navigation | ADR 0225; authorized cited source, observed source clock, focused evidence layer, and full-post navigation | Stacked candidate renders numbered answer citations and chronologically ordered evidence cards with bidirectional focus. Event and record clocks stay distinct; this list does not claim a Project Journey. Storybook and component interaction evidence are included; authenticated runtime screenshots remain required at the exact candidate head. |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
-| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback; authenticated runtime acceptance pending |
+| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback. Post-scoped evidence collection follows exact persisted `project_key` membership, and newly analyzed project evidence durably requeues completed sibling analyses that still have missing facts. Case-analysis reuse is bound to the exact ordered authorized evidence window and context, so the unchanged focal record re-analyzes when that window changes. Focused backend and replay-safe schema tests pass; authenticated exact-head runtime acceptance remains pending. |
| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending |
| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet |
| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open at `063f10f3`; stacked #251–#254 provide fail-closed input validation, full joint precision, deterministic joint plausible-value draws, and the canonical research register, while complete provenance assembly remains gated. fast-mlsirm PR #1418 validates the Rust consumer envelope but intentionally returns `EstimatorUnavailable` until the scientific estimator lands. Runtime therefore remains honestly unavailable with no local Python or fallback score. |
@@ -87,6 +87,13 @@ required before protected delivery can be claimed.
Authenticated authorized-corpus acceptance remains separate and may return
only aggregate, non-identifying evidence to this repository.
+The 2026-08-26 canonical runtime audit found the Ask composer visually
+compressed even though its asynchronous enqueue path remained responsive. The
+current stacked candidate replaces that loose control row with a labeled form,
+a stable submit action, and a separate live status. Component screenshot
+acceptance remains pending until the candidate image is rebuilt. This is not
+protected-main delivery evidence.
+
The current candidate was also re-rendered from the isolated synthetic stack
at 1440×1100 and 402×1200 after the touch-target repair. Both viewports had
document width equal to viewport width, no browser console/page errors, and no
diff --git a/frontend/src/App.css b/frontend/src/App.css
index f9bd2ee0b..020bc246d 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -465,6 +465,53 @@
opacity: 0.8;
}
+.ask-agent-panel {
+ width: min(100% - 2rem, 70rem);
+ min-height: 36rem;
+ margin: 0 auto;
+ padding: 3rem 0;
+}
+
+.ask-agent-composer {
+ display: grid;
+ gap: 0.75rem;
+ max-width: 52rem;
+ padding: 1rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-panel);
+ background: var(--surface);
+}
+
+.ask-agent-source {
+ display: grid;
+ gap: 0.5rem;
+ font-weight: 600;
+}
+
+.ask-agent-source textarea {
+ width: 100%;
+ min-height: 7rem;
+ resize: vertical;
+ padding: 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-control);
+ background: var(--surface);
+ color: inherit;
+ font: inherit;
+ line-height: 1.5;
+}
+
+.ask-agent-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+}
+
+.ask-agent-actions [role="status"] {
+ color: var(--text-muted);
+}
+
.lineage-list {
list-style: none;
padding: 0;
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 229ca26bd..856809e6d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4848,23 +4848,31 @@ function AskAgentPanel({
}
return (
-
+
{t("Evidence-grounded questions")}
{t("Ask Agent")}
{t("Questions use authorized posts and their evidence.")}
{error ? {error}
: null}
-
- {t("Ask a question")}
- setQuestion(event.target.value)}
- rows={4}
- />
-
- void handleAsk()} disabled={asking || !question.trim()}>
- {asking ? t("Asking...") : t("Ask")}
-
+ {
+ event.preventDefault();
+ void handleAsk();
+ }}>
+
+ {t("Ask a question")}
+ setQuestion(event.target.value)}
+ rows={4}
+ />
+
+
+
+ {t("Ask")}
+
+ {asking ? {t("Asking...")} : null}
+
+
{answer && (
{t("Answer")}
diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx
index affe0f78e..665b716f0 100644
--- a/frontend/src/components/OperationsDashboard.stories.tsx
+++ b/frontend/src/components/OperationsDashboard.stories.tsx
@@ -99,7 +99,7 @@ export const TopicInfluenceAccepted: Story = {
await expect(canvas.getByRole("heading", { name: "시간 흐름별 Topic model influence" })).toBeVisible();
await expect(canvas.getByText(/휴면 \/ 재활성/)).toBeVisible();
await expect(canvas.getAllByText("4.25")).toHaveLength(3);
- await expect(canvas.getByText(/순번이나 임의 가중치를 추가하지 않습니다/)).toBeVisible();
+ await expect(canvas.getByText(/영향도와 불확실성을 함께 비교하고 같은 값은 동점으로 확인하세요/)).toBeVisible();
},
};
@@ -127,7 +127,7 @@ export const RequiredFactMissing: Story = {
onOpenPost: () => undefined,
},
play: async ({ canvasElement }) => {
- await expect(within(canvasElement).getByText(/수주 Pool: 권한 범위 내 근거가 없습니다/)).toBeVisible();
+ await expect(within(canvasElement).getByText(/수주 Pool: 관련 근거를 찾으면 자동으로 다시 분석합니다. 이후 결과를 다시 확인하세요/)).toBeVisible();
},
};
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index 08dc61138..df65027b3 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -55,7 +55,7 @@ describe("OperationsDashboardView", () => {
expect(screen.getByText("3 Event · 2글")).toBeInTheDocument();
expect(screen.getByText("5건 · 25.0%")).toBeInTheDocument();
expect(screen.getByText("원인 수주")).toBeInTheDocument();
- expect(screen.getByText(/수주 Pool: 권한 범위 내 근거가 없습니다/)).toBeInTheDocument();
+ expect(screen.getByText(/수주 Pool: 관련 근거를 찾으면 자동으로 다시 분석합니다. 이후 결과를 다시 확인하세요/)).toBeInTheDocument();
expect(screen.getByText("2일 3시간 30분 0초")).toBeInTheDocument();
expect(screen.getByText(/진행 중 1건 · 종료 확인 0건/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "분류 근거 글 열기" }));
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index b9882716e..2b699505d 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -108,7 +108,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{!externalOnly ? (
관측된 처리 구간
- 임의 지연 기준 없이, 시작·종료 Event가 모두 확인된 구간만 경과 시간을 계산합니다.
+ 시작과 종료 Event가 확인된 항목의 경과 시간을 비교하세요.
{data.lifecycle_metrics.map((metric) => (
@@ -172,7 +172,7 @@ export function OperationsDashboardView({ data, externalOnly = false, onOpenPost
{item.missing_facts.length ? (
추가 확인 필요
- {item.missing_facts.map((fact) => {fact.fact_type_label}: 권한 범위 내 근거가 없습니다. 관련 원문을 연결하세요. )}
+ {item.missing_facts.map((fact) => {fact.fact_type_label}: 관련 근거를 찾으면 자동으로 다시 분석합니다. 이후 결과를 다시 확인하세요. )}
) : null}
onOpenPost(item.evidence_post_id)}>분류 근거 글 열기
@@ -223,7 +223,7 @@ export function TopicContextInfluence({ data, onOpenPost }: { data: OperationsDa
{dimensionLabels[context.dimension_code]} · {context.context_label}
- 값이 같으면 동점이며, 순번이나 임의 가중치를 추가하지 않습니다.
+ 영향도와 불확실성을 함께 비교하고 같은 값은 동점으로 확인하세요.
Event 발생일 상태 Model influence 불확실성 소속 근거 원문
{context.influences.map((influence) => (
diff --git a/lineageweave/embedding_backfill.py b/lineageweave/embedding_backfill.py
new file mode 100644
index 000000000..0e4bc7283
--- /dev/null
+++ b/lineageweave/embedding_backfill.py
@@ -0,0 +1,148 @@
+"""Atomic, cross-post embedding backfill for already-normalized semantic units."""
+
+from __future__ import annotations
+
+import asyncio
+import math
+from typing import Any
+
+from .embedding_client import ContextualOrchestratorEmbeddingClient
+from .llm_context import build_post_llm_metadata
+
+_SELECT_UNITS_SQL = """
+select unit.post_content_unit_id, unit.unit_text, unit.unit_index,
+ post.post_id, post.author_account_id, post.source_process_unit_code,
+ post.source_author_code, post.source_company_code,
+ post.source_customer_code, post.source_project_code,
+ post.source_sales_pool_code, entity.corporate_entity_code
+ from post_content_unit unit
+ join source_post post using (post_id)
+ left join corporate_entity entity using (corporate_entity_id)
+ where nullif(btrim(unit.unit_text), '') is not null
+ and not exists (
+ select 1 from post_content_embedding existing
+ where existing.post_content_unit_id = unit.post_content_unit_id
+ )
+ order by post.created_at, post.post_id, unit.unit_index
+ limit $1
+"""
+
+
+async def backfill_post_content_embeddings(
+ conn: Any,
+ embedding_client: ContextualOrchestratorEmbeddingClient,
+ *,
+ input_limit: int,
+) -> dict[str, int | str]:
+ """Embed one explicitly bounded unit set and atomically persist the complete batch.
+
+ The provider call finishes and validates every vector before the transaction
+ starts. Consequently a provider failure cannot delete or partially replace a
+ persisted embedding. ``input_limit`` is an operator-supplied work selection,
+ not a locally invented provider limit; contextual-orchestrator remains the
+ owner of provider request partitioning.
+ """
+ if input_limit < 1:
+ raise ValueError("input_limit must be positive")
+ rows = list(await conn.fetch(_SELECT_UNITS_SQL, input_limit))
+ if not rows:
+ return {"selected_units": 0, "persisted_units": 0, "dimension_values": 0}
+
+ texts = [str(row["unit_text"]) for row in rows]
+ metadata = []
+ attributions = []
+ for row in rows:
+ item_metadata = build_post_llm_metadata(str(row["post_id"]), row)
+ item_metadata["lineageweave_post_content_unit_id"] = str(
+ row["post_content_unit_id"]
+ )
+ item_metadata["lineageweave_unit_index"] = str(row["unit_index"])
+ metadata.append(item_metadata)
+ attributions.append(
+ {
+ "service": "lineageweave",
+ **(
+ {"team": str(row["source_process_unit_code"])}
+ if row["source_process_unit_code"]
+ else {}
+ ),
+ **(
+ {"company": str(row["corporate_entity_code"])}
+ if row["corporate_entity_code"]
+ else {}
+ ),
+ }
+ )
+
+ vectors = await asyncio.to_thread(
+ embedding_client.embed_many,
+ texts,
+ input_attributions=attributions,
+ input_metadata=metadata,
+ )
+ if len(vectors) != len(rows):
+ raise ValueError("embedding batch did not return one vector per input")
+ dimension_count = len(vectors[0]) if vectors else 0
+ if dimension_count < 1 or any(
+ len(vector) != dimension_count
+ or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in vector)
+ for vector in vectors
+ ):
+ raise ValueError("embedding batch returned inconsistent vectors")
+ model = embedding_client.resolved_model
+ if not model:
+ raise ValueError("embedding batch did not identify its resolved model")
+
+ unit_ids = [row["post_content_unit_id"] for row in rows]
+ async with conn.transaction():
+ await conn.executemany(
+ """
+ insert into post_content_embedding
+ (post_content_unit_id, embedding_model_code, embedding_dimension_count)
+ values ($1, $2, $3)
+ on conflict (post_content_unit_id, embedding_model_code) do update
+ set embedding_dimension_count = excluded.embedding_dimension_count,
+ created_at = now()
+ """,
+ [(unit_id, model, dimension_count) for unit_id in unit_ids],
+ )
+ embedding_rows = await conn.fetch(
+ """
+ select post_content_embedding_id, post_content_unit_id
+ from post_content_embedding
+ where embedding_model_code = $1
+ and post_content_unit_id = any($2::uuid[])
+ """,
+ model,
+ unit_ids,
+ )
+ embedding_by_unit = {
+ row["post_content_unit_id"]: row["post_content_embedding_id"]
+ for row in embedding_rows
+ }
+ if len(embedding_by_unit) != len(unit_ids):
+ raise RuntimeError("embedding headers were not persisted completely")
+ embedding_ids = [embedding_by_unit[unit_id] for unit_id in unit_ids]
+ await conn.execute(
+ "delete from post_content_embedding_value where post_content_embedding_id = any($1::uuid[])",
+ embedding_ids,
+ )
+ values = [
+ (embedding_by_unit[unit_id], dimension_index, float(dimension_value))
+ for unit_id, vector in zip(unit_ids, vectors, strict=True)
+ for dimension_index, dimension_value in enumerate(vector)
+ ]
+ await conn.executemany(
+ """
+ insert into post_content_embedding_value
+ (post_content_embedding_id, dimension_index, dimension_value)
+ values ($1, $2, $3)
+ """,
+ values,
+ )
+ return {
+ "selected_units": len(rows),
+ "persisted_units": len(rows),
+ "dimension_values": len(rows) * dimension_count,
+ "model": model,
+ }
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index 3cb4d5975..770c142ed 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -12,6 +12,7 @@
import math
import time
+from collections.abc import Mapping
from typing import Protocol
from .chunking import Chunk, chunk_by_paragraph
@@ -86,16 +87,30 @@ def embed(self, text: str) -> list[float]:
"""Return an embedding for the supplied text."""
return self.embed_many([text])[0]
- def embed_many(self, texts: list[str]) -> list[list[float]]:
- """Return embeddings for the supplied texts."""
+ def embed_many(
+ self,
+ texts: list[str],
+ *,
+ input_attributions: list[Mapping[str, object]] | None = None,
+ input_metadata: list[Mapping[str, object]] | None = None,
+ ) -> list[list[float]]:
+ """Return embeddings while preserving optional per-input provenance."""
if not texts:
return []
+ if input_attributions is not None and len(input_attributions) != len(texts):
+ raise ValueError("input_attributions must align with texts")
+ if input_metadata is not None and len(input_metadata) != len(texts):
+ raise ValueError("input_metadata must align with texts")
headers = {"authorization": f"Bearer {self._api_key}"}
payload = {
"inputs": texts,
"endpoint": "/v1/embeddings",
"metadata": {"service": "lineageweave", "channel": "post_content_embedding"},
}
+ if input_attributions is not None:
+ payload["input_attributions"] = [dict(value) for value in input_attributions]
+ if input_metadata is not None:
+ payload["input_metadata"] = [dict(value) for value in input_metadata]
if self._model is not None:
payload["model"] = self._model
response = post_json(
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py
index 74ca912d3..96df33bf1 100644
--- a/lineageweave/operations_case_analysis.py
+++ b/lineageweave/operations_case_analysis.py
@@ -121,6 +121,27 @@ def input_sha256(self) -> str:
return hashlib.sha256(self.text.encode("utf-8")).hexdigest()
+def operations_analysis_input_sha256(
+ sources: tuple[OperationsEvidenceSource, ...], context: str
+) -> str:
+ """Digest the exact ordered source window and context sent for analysis."""
+ payload = {
+ "context": context,
+ "sources": [
+ {
+ "post_id": source.post_id,
+ "title": source.title,
+ "input_sha256": source.input_sha256,
+ }
+ for source in sources
+ ],
+ }
+ encoded = json.dumps(
+ payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
class OperationsCaseAnalysisClient(Protocol):
"""Classify operational cases without keyword rules."""
@@ -382,6 +403,7 @@ def analyze(
response = post_json(
f"{self._base_url}/v1/chat/completions",
{
+ "model": "orchestrator/auto",
"messages": [
{
"role": "user",
diff --git a/migrations/0222_operations_case_analysis_input.sql b/migrations/0222_operations_case_analysis_input.sql
new file mode 100644
index 000000000..ad282fde2
--- /dev/null
+++ b/migrations/0222_operations_case_analysis_input.sql
@@ -0,0 +1,11 @@
+-- ADR 0206: bind operational case reuse to the exact authorized input window.
+alter table operations_case_analysis
+ add column if not exists analysis_input_sha256 text;
+
+alter table operations_case_analysis
+ drop constraint if exists operations_case_analysis_input_digest_check,
+ add constraint operations_case_analysis_input_digest_check
+ check (
+ analysis_input_sha256 is null
+ or analysis_input_sha256 ~ '^[0-9a-f]{64}$'
+ );
diff --git a/scripts/backfill_post_embeddings.py b/scripts/backfill_post_embeddings.py
new file mode 100755
index 000000000..48a36bad8
--- /dev/null
+++ b/scripts/backfill_post_embeddings.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""Bulk-embed existing semantic units without rebuilding or deleting their source rows."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import os
+import sys
+from pathlib import Path
+
+import asyncpg
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
+if str(REPOSITORY_ROOT) not in sys.path:
+ sys.path.insert(0, str(REPOSITORY_ROOT))
+
+from lineageweave.embedding_backfill import backfill_post_content_embeddings
+from lineageweave.embedding_client import orchestrator_embedding_client
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--limit", required=True, type=int)
+ parser.add_argument(
+ "--target-dsn",
+ default=os.environ.get(
+ "DATABASE_URL",
+ "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave",
+ ),
+ )
+ return parser
+
+
+async def _run(target_dsn: str, input_limit: int) -> dict[str, int | str]:
+ client = orchestrator_embedding_client(
+ os.environ.get("ORCHESTRATOR_BASE_URL", ""),
+ os.environ.get("ORCHESTRATOR_API_KEY", ""),
+ )
+ if not client.available:
+ raise RuntimeError("embedding is unavailable; configure contextual-orchestrator")
+ conn = await asyncpg.connect(target_dsn)
+ try:
+ return await backfill_post_content_embeddings(
+ conn, client, input_limit=input_limit
+ )
+ finally:
+ await conn.close()
+
+
+def main() -> None:
+ """Run one operator-bounded embedding batch and print aggregate counts only."""
+ args = _parser().parse_args()
+ if args.limit < 1:
+ raise SystemExit("--limit must be positive")
+ print(json.dumps(asyncio.run(_run(args.target_dsn, args.limit)), sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py
index ad65a4c95..4830dc4c6 100644
--- a/tests/test_contextual_orchestrator_start.py
+++ b/tests/test_contextual_orchestrator_start.py
@@ -65,7 +65,10 @@ def test_provider_key_is_not_aliased_as_gateway_transport(monkeypatch) -> None:
module.main()
-def test_bootstrap_leaves_embedding_selection_to_the_orchestrator(monkeypatch) -> None:
+@pytest.mark.parametrize("embedding_model", ["embedding-model", ""])
+def test_bootstrap_registers_configured_remote_embedding_agent(
+ monkeypatch, embedding_model: str
+) -> None:
module = _load_start_module()
captured: dict[str, object] = {}
@@ -111,7 +114,10 @@ def serve() -> None:
monkeypatch.setenv("BYTEZ_API_KEY", "bytez-key")
monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_TOKEN", "orchestrator-token")
monkeypatch.setenv("LLM_GATEWAY_API_URL", "https://gateway.example")
- monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_MODEL", "embedding-model")
+ if embedding_model:
+ monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_MODEL", embedding_model)
+ else:
+ monkeypatch.delenv("LLM_GATEWAY_EMBEDDING_MODEL", raising=False)
module.main()
@@ -138,5 +144,21 @@ def serve() -> None:
} & os.environ.keys()
agents = captured["agents"]
assert isinstance(agents, dict)
- assert not [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])]
+ embedding_agents = [
+ agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])
+ ]
+ if embedding_model:
+ assert embedding_agents == [
+ {
+ "id": "gateway_embedding_agent",
+ "model": embedding_model,
+ "provider_protocol": "auto",
+ "base_url": "https://gateway.example/v1",
+ "credential_key": "LLM_GATEWAY_API_KEY",
+ "tags": ["embedding"],
+ "priority": 1,
+ }
+ ]
+ else:
+ assert embedding_agents == []
assert "LLM_GATEWAY_EMBEDDING_MODEL" not in os.environ
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index b287d3470..314f21bf5 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "1a40e0f7ad10d1a24137d69d20e44fc9a5dcdd89"
+ expected_embedding_contract_commit = "d9c62be9feea24fdaeb8453f3c72f2c2b0237143"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
@@ -131,3 +131,13 @@ def test_orchestrator_runtime_pin_matches_adr() -> None:
assert adr_match is not None
assert docker_match.group(1) == adr_match.group(1)
assert docker_match.group(1) == expected_embedding_contract_commit
+
+
+def test_embedding_bootstrap_contract_keeps_request_model_free() -> None:
+ """ADR distinguishes remote-agent registration from request selection."""
+ adr = (_ADR_DIRECTORY / "0030-external-llm-gateway-environment.md").read_text(
+ encoding="utf-8"
+ )
+ assert "LineageWeave embedding requests do not select a model" in adr
+ assert "LLM_GATEWAY_EMBEDDING_MODEL" in adr
+ assert "application code never reads it" in adr
diff --git a/tests/test_embedding_backfill.py b/tests/test_embedding_backfill.py
new file mode 100644
index 000000000..01f574e91
--- /dev/null
+++ b/tests/test_embedding_backfill.py
@@ -0,0 +1,137 @@
+"""Atomic bulk embedding backfill tests with synthetic records."""
+
+from __future__ import annotations
+
+import asyncio
+import uuid
+
+import pytest
+
+from lineageweave.embedding_backfill import backfill_post_content_embeddings
+
+
+class _Transaction:
+ def __init__(self, conn):
+ self.conn = conn
+
+ async def __aenter__(self):
+ self.conn.transaction_entries += 1
+
+ async def __aexit__(self, exc_type, exc, traceback):
+ return False
+
+
+class _Connection:
+ def __init__(self, rows):
+ self.rows = rows
+ self.executemany_calls = []
+ self.execute_calls = []
+ self.transaction_entries = 0
+ self.embedding_ids = {
+ row["post_content_unit_id"]: uuid.uuid4() for row in rows
+ }
+
+ async def fetch(self, query, *args):
+ if "from post_content_unit unit" in query:
+ return self.rows
+ return [
+ {
+ "post_content_unit_id": unit_id,
+ "post_content_embedding_id": embedding_id,
+ }
+ for unit_id, embedding_id in self.embedding_ids.items()
+ ]
+
+ def transaction(self):
+ return _Transaction(self)
+
+ async def executemany(self, query, args):
+ self.executemany_calls.append((query, list(args)))
+
+ async def execute(self, query, *args):
+ self.execute_calls.append((query, args))
+
+
+class _EmbeddingClient:
+ available = True
+
+ def __init__(self, *, fail=False):
+ self.fail = fail
+ self.resolved_model = None
+ self.calls = []
+
+ def embed_many(self, texts, **kwargs):
+ self.calls.append((list(texts), kwargs))
+ if self.fail:
+ raise RuntimeError("synthetic provider failure")
+ self.resolved_model = "synthetic-embedding-model"
+ return [[float(index), 1.0] for index, _text in enumerate(texts)]
+
+
+def _row(index: int) -> dict[str, object]:
+ return {
+ "post_content_unit_id": uuid.uuid4(),
+ "unit_text": f"synthetic semantic unit {index}",
+ "unit_index": index,
+ "post_id": uuid.uuid4(),
+ "author_account_id": f"synthetic-author-{index}",
+ "source_process_unit_code": f"synthetic-team-{index}",
+ "source_author_code": None,
+ "source_company_code": None,
+ "source_customer_code": None,
+ "source_project_code": None,
+ "source_sales_pool_code": None,
+ "corporate_entity_code": f"synthetic-company-{index}",
+ }
+
+
+def test_bulk_backfill_calls_provider_once_and_persists_in_one_transaction() -> None:
+ rows = [_row(0), _row(1)]
+ conn = _Connection(rows)
+ client = _EmbeddingClient()
+
+ result = asyncio.run(backfill_post_content_embeddings(conn, client, input_limit=2))
+
+ assert result == {
+ "selected_units": 2,
+ "persisted_units": 2,
+ "dimension_values": 4,
+ "model": "synthetic-embedding-model",
+ }
+ assert len(client.calls) == 1
+ assert len(client.calls[0][0]) == 2
+ assert [item["team"] for item in client.calls[0][1]["input_attributions"]] == [
+ "synthetic-team-0",
+ "synthetic-team-1",
+ ]
+ assert len(client.calls[0][1]["input_metadata"]) == 2
+ assert conn.transaction_entries == 1
+ assert len(conn.executemany_calls) == 2
+ assert len(conn.executemany_calls[1][1]) == 4
+
+
+def test_provider_failure_makes_no_database_change() -> None:
+ conn = _Connection([_row(0), _row(1)])
+ client = _EmbeddingClient(fail=True)
+
+ with pytest.raises(RuntimeError, match="synthetic provider failure"):
+ asyncio.run(backfill_post_content_embeddings(conn, client, input_limit=2))
+
+ assert conn.transaction_entries == 0
+ assert conn.executemany_calls == []
+ assert conn.execute_calls == []
+
+
+def test_empty_selection_skips_provider_and_transaction() -> None:
+ conn = _Connection([])
+ client = _EmbeddingClient()
+
+ result = asyncio.run(backfill_post_content_embeddings(conn, client, input_limit=1))
+
+ assert result == {
+ "selected_units": 0,
+ "persisted_units": 0,
+ "dimension_values": 0,
+ }
+ assert client.calls == []
+ assert conn.transaction_entries == 0
diff --git a/tests/test_embedding_client.py b/tests/test_embedding_client.py
index 8a424a4e4..a604e5330 100644
--- a/tests/test_embedding_client.py
+++ b/tests/test_embedding_client.py
@@ -123,3 +123,38 @@ def fake_get_json(url, *, headers, timeout, service_peer_name):
assert client.embed_many(["third", "fourth"]) == [[0.0, 1.0], [2.0, 3.0]]
assert calls[2][2]["model"] == "resolved-embedding"
+
+
+def test_orchestrator_embedding_client_submits_index_aligned_provenance(monkeypatch) -> None:
+ """Each bulk input carries its own source metadata and cost attribution."""
+ captured = {}
+
+ def fake_post_json(url, payload, *, headers, timeout):
+ captured.update(payload)
+ return {
+ "status": "completed",
+ "model": "resolved-embedding",
+ "embeddings": [
+ {"index": 0, "embedding": [1.0]},
+ {"index": 1, "embedding": [2.0]},
+ ],
+ }
+
+ monkeypatch.setattr("lineageweave.embedding_client.post_json", fake_post_json)
+ client = ContextualOrchestratorEmbeddingClient(
+ "http://orchestrator:8000", "synthetic-token"
+ )
+
+ assert client.embed_many(
+ ["first", "second"],
+ input_attributions=[{"team": "alpha"}, {"team": "beta"}],
+ input_metadata=[{"session_id": "one"}, {"session_id": "two"}],
+ ) == [[1.0], [2.0]]
+ assert captured["input_attributions"] == [
+ {"team": "alpha"},
+ {"team": "beta"},
+ ]
+ assert captured["input_metadata"] == [
+ {"session_id": "one"},
+ {"session_id": "two"},
+ ]
diff --git a/tests/test_embedding_client_edges.py b/tests/test_embedding_client_edges.py
index 1c6839781..2fa205429 100644
--- a/tests/test_embedding_client_edges.py
+++ b/tests/test_embedding_client_edges.py
@@ -2,7 +2,7 @@
import pytest
-import lineageweave.embedding_client as embedding_client
+from lineageweave import embedding_client
def test_missing_embedding_configuration_returns_null_client() -> None:
@@ -19,6 +19,16 @@ def test_empty_batch_does_not_call_orchestrator(monkeypatch: pytest.MonkeyPatch)
assert client.embed_many([]) == []
+@pytest.mark.parametrize("field", ["input_attributions", "input_metadata"])
+def test_per_input_context_must_align_with_texts(field: str) -> None:
+ client = embedding_client.ContextualOrchestratorEmbeddingClient(
+ "http://orchestrator", "key", "model"
+ )
+
+ with pytest.raises(ValueError, match=field):
+ client.embed_many(["first", "second"], **{field: [{"key": "value"}]})
+
+
def test_immediate_embedding_response_is_ordered(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
embedding_client,
diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py
index a17e5a898..8f4d6e9e7 100644
--- a/tests/test_operations_case_analysis.py
+++ b/tests/test_operations_case_analysis.py
@@ -2,12 +2,52 @@
import json
+from lineageweave import operations_case_analysis
from lineageweave.operations_case_analysis import (
+ ContextualOrchestratorOperationsCaseAnalysisClient,
OperationsEvidenceSource,
+ operations_analysis_input_sha256,
parse_operations_case_response,
)
+def test_orchestrator_request_uses_provider_neutral_auto_selector(monkeypatch) -> None:
+ """The consumer selects orchestrator routing, never a provider model name."""
+ captured: dict[str, object] = {}
+
+ def post_json(_url, payload, **_kwargs):
+ captured.update(payload)
+ return {"choices": [{"message": {"content": "[]"}}]}
+
+ monkeypatch.setattr(operations_case_analysis, "post_json", post_json)
+ client = ContextualOrchestratorOperationsCaseAnalysisClient("gateway", "key")
+
+ assert client.analyze(
+ (OperationsEvidenceSource("post-1", "Synthetic", "Synthetic source."),),
+ "",
+ ) == ()
+ assert captured["model"] == "orchestrator/auto"
+
+
+def test_analysis_input_digest_tracks_ordered_evidence_and_context() -> None:
+ """Cache identity changes when any orchestrator input changes."""
+ first = OperationsEvidenceSource("post-1", "First", "Evidence one")
+ second = OperationsEvidenceSource("post-2", "Second", "Evidence two")
+
+ baseline = operations_analysis_input_sha256((first, second), "project=P-1")
+
+ assert len(baseline) == 64
+ assert baseline == operations_analysis_input_sha256(
+ (first, second), "project=P-1"
+ )
+ assert baseline != operations_analysis_input_sha256(
+ (second, first), "project=P-1"
+ )
+ assert baseline != operations_analysis_input_sha256(
+ (first, second), "project=P-2"
+ )
+
+
def test_parses_multiple_cases_and_grounded_facts() -> None:
"""One record may support multiple case kinds without losing evidence."""
body = "The revised specification caused the claim. Mina agreed with Alex to rebid."
diff --git a/tests/test_operations_case_ingestion.py b/tests/test_operations_case_ingestion.py
index f24b1f3eb..e540996bf 100644
--- a/tests/test_operations_case_ingestion.py
+++ b/tests/test_operations_case_ingestion.py
@@ -51,9 +51,13 @@ def test_digest_and_atomic_normalized_persistence() -> None:
digest,
),
)
- asyncio.run(persist_operations_cases(conn, "post-1", "source", "session-1", cases))
+ asyncio.run(persist_operations_cases(
+ conn, "post-1", "source", "session-1", cases,
+ analysis_input_sha256="b" * 64,
+ ))
assert len(source_body_digest("source")) == 64
assert "delete from operations_case_analysis" in conn.calls[0][0]
+ assert conn.calls[1][1][-1] == "b" * 64
assert conn.batches == [
[("post-1", "claim_investigation", 0, "order", "A-1", "source", "post-1", digest, None)]
]
@@ -62,7 +66,10 @@ def test_digest_and_atomic_normalized_persistence() -> None:
def test_persists_supported_empty_analysis() -> None:
"""A completed no-case result is recorded without fabricated children."""
conn = _Connection()
- asyncio.run(persist_operations_cases(conn, "post-1", "ordinary", "session-1", ()))
+ asyncio.run(persist_operations_cases(
+ conn, "post-1", "ordinary", "session-1", (),
+ analysis_input_sha256="b" * 64,
+ ))
assert len(conn.calls) == 2
assert conn.batches == []
@@ -81,7 +88,10 @@ def test_persists_missing_required_facts_without_invented_evidence() -> None:
)
asyncio.run(
- persist_operations_cases(conn, "post-1", "source", "session-1", (case,))
+ persist_operations_cases(
+ conn, "post-1", "source", "session-1", (case,),
+ analysis_input_sha256="b" * 64,
+ )
)
assert conn.batches == [
@@ -120,7 +130,10 @@ def test_persists_observed_and_missing_milestones_separately() -> None:
)
asyncio.run(
- persist_operations_cases(conn, "post-1", "source", "session-1", (case,))
+ persist_operations_cases(
+ conn, "post-1", "source", "session-1", (case,),
+ analysis_input_sha256="b" * 64,
+ )
)
assert conn.batches[-2] == [
diff --git a/tests/test_post_chat_ingestion.py b/tests/test_post_chat_ingestion.py
index 6626d85ac..71cbd755b 100644
--- a/tests/test_post_chat_ingestion.py
+++ b/tests/test_post_chat_ingestion.py
@@ -7,14 +7,18 @@
import pytest
from backend.app.post_chat_ingestion import (
+ _POST_CHAT_CANDIDATE_LIMIT,
LinkedPostIds,
cited_post_images,
fetch_persisted_chat,
fetch_persisted_chats,
+ find_linked_post_ids,
+ find_project_sibling_post_ids,
gather_chat_sources,
normalize_chat_question,
persist_post_chat,
)
+from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from lineageweave.post_chat import (
ChatSourceDocument,
ContextualOrchestratorPostChatClient,
@@ -69,6 +73,83 @@ async def fetch(self, _query: str, *_args: object):
return []
+def test_project_siblings_are_separate_from_event_lineage(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ class ProjectConnection:
+ project_queries = 0
+
+ async def fetch(self, query: str, *_args: object):
+ if "post_lineage_edge" in query or "select distinct person_id" in query:
+ return []
+ if "select distinct project_key" in query:
+ self.project_queries += 1
+ return [{"project_key": "project-synthetic"}]
+ if "where ppm.project_key = any" in query:
+ assert SOURCE_POST_ELIGIBILITY_SQL.format(alias="sp") in query
+ assert _args[1] == "post-1"
+ return [{"post_id": "post-2"}]
+ return []
+
+ async def no_graph(_conn: object, post_ids: list[str]):
+ assert post_ids == ["post-1"]
+ return []
+
+ monkeypatch.setattr(
+ "backend.app.post_chat_ingestion.load_visible_subgraph",
+ no_graph,
+ )
+ connection = ProjectConnection()
+ linked = asyncio.run(find_linked_post_ids(connection, "post-1"))
+ siblings = asyncio.run(find_project_sibling_post_ids(connection, "post-1"))
+
+ assert linked == LinkedPostIds(direct=frozenset(), indirect=frozenset())
+ assert siblings == frozenset({"post-2"})
+ assert connection.project_queries == 1
+
+
+def test_project_sibling_precedes_a_dense_graph_candidate_window(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Exact project evidence is not crowded out by a dense graph window."""
+
+ root_id = "00000000-0000-0000-0000-000000000001"
+ project_id = "00000000-0000-0000-9999-999999999999"
+ direct_ids = {
+ f"00000000-0000-0000-0001-{index:012d}" for index in range(40)
+ }
+ direct_ids.add(project_id)
+
+ class DenseConnection(_SourceConnection):
+ candidate_ids: list[str] = []
+
+ async def fetch(self, query: str, *args: object):
+ if "select post_id, post_title, post_body, visibility_code" in query:
+ self.candidate_ids = list(args[0])
+ return []
+ return []
+
+ async def dense_links(_conn: object, _post_id: str) -> LinkedPostIds:
+ return LinkedPostIds(frozenset(direct_ids), frozenset())
+
+ async def project_link(_conn: object, _post_id: str) -> frozenset[str]:
+ return frozenset({project_id})
+
+ monkeypatch.setattr(
+ "backend.app.post_chat_ingestion.find_linked_post_ids", dense_links
+ )
+ monkeypatch.setattr(
+ "backend.app.post_chat_ingestion.find_project_sibling_post_ids",
+ project_link,
+ )
+ connection = DenseConnection()
+
+ asyncio.run(gather_chat_sources(connection, root_id, lambda _row: True))
+
+ assert connection.candidate_ids[0] == project_id
+ assert len(connection.candidate_ids) == _POST_CHAT_CANDIDATE_LIMIT
+
+
def test_gather_chat_sources_keeps_the_event_loop_responsive_during_body_normalization(
monkeypatch: pytest.MonkeyPatch,
) -> None:
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index 3a9d122df..6b0fe4743 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -41,6 +41,9 @@ def transaction(self) -> _Transaction:
async def fetchrow(self, *_args: object):
return self.row
+ async def fetch(self, *_args: object):
+ return []
+
async def fetchval(self, query: str, *_args: object):
if self.values:
return self.values.pop(0)
@@ -172,6 +175,63 @@ async def fetch(self, *_args: object):
))
+def test_new_project_evidence_requeues_siblings_with_missing_facts(monkeypatch) -> None:
+ """A newly analyzed project post wakes completed missing-fact analyses."""
+ sibling_id = "00000000-0000-0000-0000-000000000002"
+
+ class MissingFactConnection(_Connection):
+ async def fetch(self, query: str, *_args: object):
+ if "operations_case_missing_fact" in query:
+ assert _args[1] == SUCCEEDED
+ return [{"post_id": sibling_id, "post_body": "Synthetic sibling body"}]
+ return []
+
+ async def siblings(_conn, _post_id):
+ return frozenset({sibling_id})
+
+ queued: list[tuple[str, str, bool]] = []
+
+ async def ensure(_conn, post_id, body, *, content_complete):
+ queued.append((post_id, body, content_complete))
+ return SimpleNamespace(should_publish=True)
+
+ monkeypatch.setattr(post_content_worker, "find_project_sibling_post_ids", siblings)
+ monkeypatch.setattr(post_content_worker, "ensure_post_content_job", ensure)
+
+ count = asyncio.run(
+ post_content_worker._requeue_project_missing_case_jobs(
+ _Pool(MissingFactConnection()),
+ "00000000-0000-0000-0000-000000000001",
+ )
+ )
+
+ assert count == 1
+ assert queued == [(sibling_id, "Synthetic sibling body", False)]
+
+
+def test_missing_fact_requeue_stops_without_project_siblings(monkeypatch) -> None:
+ """An unlinked post does not create speculative retry work."""
+
+ async def no_siblings(_conn, _post_id):
+ return frozenset()
+
+ monkeypatch.setattr(
+ post_content_worker,
+ "find_project_sibling_post_ids",
+ no_siblings,
+ )
+
+ assert (
+ asyncio.run(
+ post_content_worker._requeue_project_missing_case_jobs(
+ _Pool(_Connection()),
+ "00000000-0000-0000-0000-000000000001",
+ )
+ )
+ == 0
+ )
+
+
def test_terminal_failed_job_ignores_a_stale_duplicate_wakeup() -> None:
connection = _Connection(_row(FAILED, POST_CONTENT_MAX_ATTEMPTS))
@@ -245,7 +305,7 @@ async def incomplete(*_args, **_kwargs) -> bool:
def test_incomplete_provider_output_is_requeued_with_a_failure_code(monkeypatch) -> None:
- connection = _Connection(values=[2])
+ connection = _Connection(values=[False, 2])
pool = _Pool(connection)
async def claim(*_args, **_kwargs):
@@ -304,6 +364,203 @@ async def evidence_sources(*_args, **_kwargs):
assert analyzed_bodies == ["A synthetic post body with a retrieval unit."]
+def test_existing_case_analysis_skips_duplicate_orchestrator_call(monkeypatch) -> None:
+ """A retry preserves the same exact input without another provider call."""
+ connection = _Connection(values=[True])
+ called: list[str] = []
+
+ async def evidence_sources(*_args, **_kwargs):
+ return (OperationsEvidenceSource("post-1", "Synthetic", "Evidence"),)
+
+ monkeypatch.setattr(
+ post_content_worker, "_operations_evidence_sources", evidence_sources
+ )
+ monkeypatch.setattr(
+ post_content_worker,
+ "ContextualOrchestratorOperationsCaseAnalysisClient",
+ lambda *_args: called.append("client") or SimpleNamespace(),
+ )
+
+ asyncio.run(
+ post_content_worker._persist_operations_case_analysis_if_needed(
+ _Pool(connection),
+ "00000000-0000-0000-0000-000000000001",
+ "a" * 64,
+ "Synthetic source body",
+ _row(RUNNING, 1),
+ SimpleNamespace(available=True),
+ "synthetic-session",
+ "gateway",
+ "key",
+ )
+ )
+
+ assert called == []
+
+
+def test_changed_evidence_window_reanalyzes_unchanged_body(monkeypatch) -> None:
+ """A newly available sibling invalidates reuse without changing focal text."""
+ connection = _Connection(values=[False])
+ analyzed: list[tuple[OperationsEvidenceSource, ...]] = []
+ persisted: list[str] = []
+
+ async def evidence_sources(*_args, **_kwargs):
+ return (
+ OperationsEvidenceSource("post-1", "Focal", "Focal evidence"),
+ OperationsEvidenceSource("post-2", "Sibling", "New sibling evidence"),
+ )
+
+ async def persist(*_args, **kwargs):
+ persisted.append(str(kwargs["analysis_input_sha256"]))
+
+ monkeypatch.setattr(
+ post_content_worker, "_operations_evidence_sources", evidence_sources
+ )
+ monkeypatch.setattr(
+ post_content_worker,
+ "ContextualOrchestratorOperationsCaseAnalysisClient",
+ lambda *_args: SimpleNamespace(
+ analyze=lambda sources, _context: analyzed.append(sources) or ()
+ ),
+ )
+ monkeypatch.setattr(post_content_worker, "persist_operations_cases", persist)
+
+ asyncio.run(
+ post_content_worker._persist_operations_case_analysis_if_needed(
+ _Pool(connection),
+ "00000000-0000-0000-0000-000000000001",
+ "a" * 64,
+ "Synthetic source body",
+ _row(RUNNING, 1),
+ SimpleNamespace(available=True),
+ "synthetic-session",
+ "gateway",
+ "key",
+ )
+ )
+
+ assert [source.post_id for source in analyzed[0]] == ["post-1", "post-2"]
+ assert len(persisted[0]) == 64
+
+
+def test_sibling_requeue_failure_preserves_completed_primary_job(monkeypatch) -> None:
+ """Ancillary retry discovery cannot fail already-persisted post evidence."""
+ outcomes: list[str] = []
+
+ async def claim(*_args, **_kwargs):
+ return _row(RUNNING, 1)
+
+ async def complete(*_args, **_kwargs):
+ return True
+
+ async def fail_requeue(*_args, **_kwargs):
+ raise OSError("synthetic sibling lookup outage")
+
+ async def finish(_pool, _post_id, status, **_kwargs):
+ outcomes.append(status)
+
+ monkeypatch.setattr(post_content_worker, "_claim_job", claim)
+ monkeypatch.setattr(
+ post_content_worker,
+ "load_settings",
+ lambda: SimpleNamespace(orchestrator_base_url="gateway", orchestrator_api_key="key"),
+ )
+ monkeypatch.setattr(
+ post_content_worker,
+ "_persist_operations_case_analysis_if_needed",
+ lambda *_args, **_kwargs: asyncio.sleep(0),
+ )
+ monkeypatch.setattr(post_content_worker, "normalize_post_body", lambda *_args: object())
+ monkeypatch.setattr(
+ post_content_worker,
+ "persist_post_content",
+ lambda *_args, **_kwargs: asyncio.sleep(0),
+ )
+ monkeypatch.setattr(post_content_worker, "post_content_is_complete", complete)
+ monkeypatch.setattr(post_content_worker, "_requeue_project_missing_case_jobs", fail_requeue)
+ monkeypatch.setattr(post_content_worker, "_finish_job", finish)
+ monkeypatch.setattr(
+ post_content_worker, "record_server_failure", lambda *_args, **_kwargs: None
+ )
+ client = SimpleNamespace(available=True, resolved_model="synthetic-model")
+
+ asyncio.run(
+ post_content_worker.process_post_content_job(
+ _Pool(_Connection()),
+ post_id="00000000-0000-0000-0000-000000000001",
+ source_body_digest="a" * 64,
+ vision_factory=lambda: client,
+ embedding_factory=lambda: client,
+ structure_factory=lambda: client,
+ )
+ )
+
+ assert outcomes == [SUCCEEDED]
+
+
+def test_case_analysis_persists_before_content_provider_failure(monkeypatch) -> None:
+ """Independent case evidence survives a later structure or embedding outage."""
+ connection = _Connection(values=[False, 2])
+ pool = _Pool(connection)
+ persisted: list[str] = []
+
+ async def claim(*_args, **_kwargs):
+ return _row(RUNNING, 1)
+
+ async def fail_content(*_args, **_kwargs):
+ raise TimeoutError("synthetic provider timeout")
+
+ async def evidence_sources(*_args, **_kwargs):
+ return (
+ OperationsEvidenceSource(
+ "post-1", "Synthetic", "A synthetic source body."
+ ),
+ )
+
+ async def persist_cases(_conn, _post_id, *_args, **_kwargs):
+ persisted.append("cases")
+
+ monkeypatch.setattr(post_content_worker, "_claim_job", claim)
+ monkeypatch.setattr(post_content_worker, "persist_post_content", fail_content)
+ monkeypatch.setattr(
+ post_content_worker,
+ "load_settings",
+ lambda: SimpleNamespace(
+ orchestrator_base_url="gateway", orchestrator_api_key="key"
+ ),
+ )
+ monkeypatch.setattr(
+ post_content_worker, "_operations_evidence_sources", evidence_sources
+ )
+ monkeypatch.setattr(
+ post_content_worker,
+ "ContextualOrchestratorOperationsCaseAnalysisClient",
+ lambda *_args: SimpleNamespace(analyze=lambda *_args: ()),
+ )
+ monkeypatch.setattr(post_content_worker, "persist_operations_cases", persist_cases)
+ monkeypatch.setattr(
+ post_content_worker, "normalize_post_body", lambda *_args: object()
+ )
+ client = SimpleNamespace(available=True)
+
+ asyncio.run(
+ post_content_worker.process_post_content_job(
+ pool,
+ post_id="00000000-0000-0000-0000-000000000001",
+ source_body_digest="a" * 64,
+ vision_factory=lambda: client,
+ embedding_factory=lambda: client,
+ structure_factory=lambda: client,
+ )
+ )
+
+ assert persisted == ["cases"]
+ updates = [
+ args for query, args in connection.executed if "set status_code" in query
+ ]
+ assert any(args[1] == QUEUED for args in updates)
+
+
def test_missing_source_body_is_not_reported_as_a_provider_failure(monkeypatch, caplog) -> None:
connection = _Connection(values=[2])
pool = _Pool(connection)
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 62040411b..16ad44983 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -117,6 +117,11 @@
/ "migrations"
/ "0216_validate_operations_case_constraints.sql"
)
+_OPERATIONS_CASE_INPUT_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0222_operations_case_analysis_input.sql"
+)
_ANALYSIS_RUN_REGISTRY_MIGRATION = (
Path(__file__).resolve().parents[1] / "migrations" / "0018_analysis_run_registry.sql"
)
@@ -191,6 +196,7 @@ def schema_db():
cur.execute(_OPERATIONS_EXTERNAL_RELATION_MIGRATION.read_text())
cur.execute(_OPERATIONS_CASE_MILESTONE_MIGRATION.read_text())
cur.execute(_OPERATIONS_CASE_CONSTRAINT_VALIDATION_MIGRATION.read_text())
+ cur.execute(_OPERATIONS_CASE_INPUT_MIGRATION.read_text())
conn.commit()
yield conn
finally:
@@ -280,6 +286,24 @@ def test_operations_case_constraints_are_validated(schema_db) -> None:
assert constraints == {name: True for name in names}
+def test_operations_case_input_fingerprint_rejects_malformed_digest(schema_db) -> None:
+ """The input fingerprint is nullable only for honest historical unknowns."""
+ with schema_db.cursor() as cur:
+ cur.execute(
+ "select is_nullable from information_schema.columns "
+ "where table_name = 'operations_case_analysis' "
+ "and column_name = 'analysis_input_sha256'"
+ )
+ assert cur.fetchone() == ("YES",)
+ cur.execute(
+ "select pg_get_constraintdef(oid) from pg_constraint "
+ "where conname = 'operations_case_analysis_input_digest_check'"
+ )
+ definition = cur.fetchone()[0]
+ assert "analysis_input_sha256 IS NULL" in definition
+ assert "^[0-9a-f]{64}$" in definition
+
+
def test_operations_case_milestones_reject_cross_kind_types(schema_db) -> None:
"""Observed and missing endpoints accept only the activity set for their case."""
post_id = uuid.uuid4()
From fbf7afb5a3037254266e5cf1bb024e435b126bb1 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 25 Aug 2026 21:26:23 -0700
Subject: [PATCH 117/393] fix(deps): pin accepted measurement owner release
(#689)
Co-authored-by: Codex
---
.../0208-externalize-local-mathematical-compute.md | 14 +++++++-------
docs/product-technical-gap-baseline.md | 2 +-
pyproject.toml | 9 ++++-----
uv.lock | 6 +++---
4 files changed, 15 insertions(+), 16 deletions(-)
diff --git a/docs/adr/0208-externalize-local-mathematical-compute.md b/docs/adr/0208-externalize-local-mathematical-compute.md
index d0a2c6e5a..4305ffb53 100644
--- a/docs/adr/0208-externalize-local-mathematical-compute.md
+++ b/docs/adr/0208-externalize-local-mathematical-compute.md
@@ -71,13 +71,13 @@ different responsibility.
## Implemented migration slices
-- The backend dependency is immutably pinned to fast-mlsirm commit
- `b3d85c35856fa8f7376821084f93292d0dea0407`, which publishes the strict
- `tepp.lineage_pair_criterion_posterior.v2` minimum producer schema and keeps
- channel-weight estimation unavailable. This is a contract adapter only: the
- legacy Python estimator remains frozen migration debt and MUST NOT be used to
- activate calibrated weights. No consumer-facing projection exposes schema,
- transport, hash, TEPP, or fast-mlsirm internals.
+- The backend dependency is immutably pinned to fast-mlsirm protected-main
+ commit `09f762ded35786dd1078222a4577ff09d649816f`. The TEPP-specific contract
+ proposed by closed, unmerged fast-mlsirm PR #1423 is not an owner contract
+ and is not consumed. Channel-weight estimation remains unavailable until a
+ domain-neutral owner contract lands; the legacy Python estimator remains
+ frozen migration debt and MUST NOT activate calibrated weights. No customer
+ projection exposes schema, transport, hash, TEPP, or fast-mlsirm internals.
- The residual interaction map consumes fast-mlsirm's protected-main
`residual_interaction_map` and `polytomous_expected_response` contracts.
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 2042cb85c..c61a43617 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -461,7 +461,7 @@ this file per §3.5 of the prior snapshot).
| SKOS organization aliases | Catalog binding and chip caption live on #480 / #482 | One catalog row per corroborated org; companion caption is hint-only until bound |
| Event Lineage evidence | Channel evidence and Allen relations live on #387 / #484 | Persist channel scores, explain them in the popup, never invent a fused score |
| Scientific measurement | Durable accepted TEPP receipts and LineageWeave #614's exact accepted snapshot/cutoff/run/pair-count consumer are protected; TEPP #237 remains open, so no registered producer artifact exists yet. #387 removes inferred/default persistence weights, but several older reconstruction tests still pass hand-authored numeric dictionaries that are not estimator evidence | Land TEPP #237 through its protected gate, then replace remaining reconstruction-test constants with provenance-bearing fast-mlsirm estimates over synthetic fixtures. Retain true-parameter RMSE recovery as the acceptance bar |
-| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stack also removes local RRF contribution arithmetic and corrects the invalid all-ones call to RankWeave's convex API through RankWeave #47 (`f92c3a2c`), but RankWeave #47 is still Python and therefore is not the final Rust CPU/GPU execution contract. The backend now immutably pins fast-mlsirm owner PR #1423 head `b3d85c35856fa8f7376821084f93292d0dea0407`, which validates the accepted-anchor prerequisite and publishes the exact pair-level posterior producer schema while returning unavailable instead of inventing weights. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Obtain independent pair-level criterion posterior evidence with TDT/CHRONOS event-time provenance and CPU/GPU parity receipts; only then land the Rust estimator and delete the frozen Python channel-weight path. Continue the separate RankWeave and period-report owner migrations before requiring an empty transition inventory |
+| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stack also removes local RRF contribution arithmetic and corrects the invalid all-ones call to RankWeave's convex API through RankWeave #47 (`f92c3a2c`), but RankWeave #47 is still Python and therefore is not the final Rust CPU/GPU execution contract. The backend pins fast-mlsirm protected-main `09f762ded35786dd1078222a4577ff09d649816f`; TEPP-specific fast-mlsirm PR #1423 closed unmerged and is not a valid owner contract. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Define and land a domain-neutral pair-level criterion-observation contract in fast-mlsirm, with independent event-time provenance and CPU/GPU parity receipts; only then land the Rust estimator and delete the frozen Python channel-weight path. Continue the separate RankWeave and period-report owner migrations before requiring an empty transition inventory |
| Asynchronous authorization | Protected `main` rebuilds Global Ask worker scope after the bearer token leaves the request; #468 now persists exact Keyverse organization/process-unit scope in 3NF child tables and intersects it with current affiliations | Land #468 through the protected gate; prove a second affiliation and a revoked process unit cannot widen delayed-job evidence |
| Planned-facility intent | Planned-facility relationship intent remains only on closed, unmerged #490; earlier stack-only merges were not protected delivery | Recreate the evidence-backed slice on a current base and land through protected `main` before a release claim |
| Accessibility and responsive UX | #602 delivered base post-detail modal semantics; #605 adds selected-post refocus, collapsed/hidden/inert/CSS-invisible focus exclusion across both modal types, readable evidence separators, focused tests, and desktop/mobile Storybook screenshots | Land #605 through the protected gate, then complete screen-reader and authenticated Playwright acceptance on the exact release head |
diff --git a/pyproject.toml b/pyproject.toml
index 56078316e..f2e8bb1ef 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -51,11 +51,10 @@ backend = [
# Speaks RESP; works against Valkey (a Redis-protocol-compatible fork)
# as well as real Redis. Used for the post-activity event stream.
"redis>=5.0.0",
- # Owner arithmetic plus the fail-closed TEPP lineage criterion consumer
- # contract (ADR 0208). The exact commit is immutable; it still exposes no
- # channel-weight result until independent pair-level posterior evidence is
- # available. PyO3/maturin source builds need the pinned Rust toolchain.
- "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@b3d85c35856fa8f7376821084f93292d0dea0407",
+ # Owner arithmetic from protected main (ADR 0208). Product-specific lineage
+ # contracts remain unavailable until a domain-neutral owner contract lands.
+ # PyO3/maturin source builds need the pinned Rust toolchain.
+ "fast-mlsirm @ git+https://github.com/ContextualWisdomLab/fast-mlsirm.git@09f762ded35786dd1078222a4577ff09d649816f",
]
[tool.setuptools.packages.find]
diff --git a/uv.lock b/uv.lock
index be5f249f0..7d11d7af9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -470,8 +470,8 @@ wheels = [
[[package]]
name = "fast-mlsirm"
-version = "0.9.0"
-source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=b3d85c35856fa8f7376821084f93292d0dea0407#b3d85c35856fa8f7376821084f93292d0dea0407" }
+version = "0.9.1"
+source = { git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=09f762ded35786dd1078222a4577ff09d649816f#09f762ded35786dd1078222a4577ff09d649816f" }
dependencies = [
{ name = "numpy" },
]
@@ -655,7 +655,7 @@ requires-dist = [
{ name = "certifi", specifier = ">=2024.0.0" },
{ name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" },
{ name = "cryptography", specifier = ">=42.0" },
- { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=b3d85c35856fa8f7376821084f93292d0dea0407" },
+ { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=09f762ded35786dd1078222a4577ff09d649816f" },
{ name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.141.1" },
{ name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.12.0" },
{ name = "opentelemetry-api", specifier = ">=1.30.0" },
From 70e45752e3df418cf9666d14d9c3bcd08f497f2a Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:31:05 +0900
Subject: [PATCH 118/393] perf(embedding): pack batches to advertised body
limit
---
lineageweave/embedding_backfill.py | 71 +++++++++++++++++++---------
lineageweave/embedding_client.py | 71 +++++++++++++++++++++++-----
scripts/backfill_post_embeddings.py | 12 ++---
tests/test_embedding_backfill.py | 35 ++++++++++++--
tests/test_embedding_client_edges.py | 22 +++++++++
5 files changed, 168 insertions(+), 43 deletions(-)
diff --git a/lineageweave/embedding_backfill.py b/lineageweave/embedding_backfill.py
index 0e4bc7283..bf9993377 100644
--- a/lineageweave/embedding_backfill.py
+++ b/lineageweave/embedding_backfill.py
@@ -10,21 +10,27 @@
from .llm_context import build_post_llm_metadata
_SELECT_UNITS_SQL = """
-select unit.post_content_unit_id, unit.unit_text, unit.unit_index,
- post.post_id, post.author_account_id, post.source_process_unit_code,
- post.source_author_code, post.source_company_code,
- post.source_customer_code, post.source_project_code,
- post.source_sales_pool_code, entity.corporate_entity_code
- from post_content_unit unit
- join source_post post using (post_id)
- left join corporate_entity entity using (corporate_entity_id)
- where nullif(btrim(unit.unit_text), '') is not null
- and not exists (
- select 1 from post_content_embedding existing
- where existing.post_content_unit_id = unit.post_content_unit_id
- )
- order by post.created_at, post.post_id, unit.unit_index
- limit $1
+with candidates as (
+ select unit.post_content_unit_id, unit.unit_text, unit.unit_index,
+ post.post_id, post.author_account_id, post.source_process_unit_code,
+ post.source_author_code, post.source_company_code,
+ post.source_customer_code, post.source_project_code,
+ post.source_sales_pool_code, entity.corporate_entity_code,
+ sum(octet_length(unit.unit_text) + 1) over (
+ order by post.created_at, post.post_id, unit.unit_index
+ ) as cumulative_text_bytes
+ from post_content_unit unit
+ join source_post post using (post_id)
+ left join corporate_entity entity using (corporate_entity_id)
+ where nullif(btrim(unit.unit_text), '') is not null
+ and not exists (
+ select 1 from post_content_embedding existing
+ where existing.post_content_unit_id = unit.post_content_unit_id
+ )
+)
+select * from candidates
+ where cumulative_text_bytes <= $1
+ order by cumulative_text_bytes
"""
@@ -32,19 +38,18 @@ async def backfill_post_content_embeddings(
conn: Any,
embedding_client: ContextualOrchestratorEmbeddingClient,
*,
- input_limit: int,
+ max_request_body_bytes: int,
) -> dict[str, int | str]:
"""Embed one explicitly bounded unit set and atomically persist the complete batch.
The provider call finishes and validates every vector before the transaction
starts. Consequently a provider failure cannot delete or partially replace a
- persisted embedding. ``input_limit`` is an operator-supplied work selection,
- not a locally invented provider limit; contextual-orchestrator remains the
- owner of provider request partitioning.
+ persisted embedding. The candidate query and final prefix are both bounded
+ by contextual-orchestrator's advertised request-body ceiling.
"""
- if input_limit < 1:
- raise ValueError("input_limit must be positive")
- rows = list(await conn.fetch(_SELECT_UNITS_SQL, input_limit))
+ if max_request_body_bytes < 1:
+ raise ValueError("max_request_body_bytes must be positive")
+ rows = list(await conn.fetch(_SELECT_UNITS_SQL, max_request_body_bytes))
if not rows:
return {"selected_units": 0, "persisted_units": 0, "dimension_values": 0}
@@ -74,6 +79,28 @@ async def backfill_post_content_embeddings(
}
)
+ selected_count = 0
+ lower = 1
+ upper = len(rows)
+ while lower <= upper:
+ candidate_count = (lower + upper) // 2
+ body_size = embedding_client.batch_request_body_size(
+ texts[:candidate_count],
+ input_attributions=attributions[:candidate_count],
+ input_metadata=metadata[:candidate_count],
+ )
+ if body_size > max_request_body_bytes:
+ upper = candidate_count - 1
+ else:
+ selected_count = candidate_count
+ lower = candidate_count + 1
+ if selected_count == 0:
+ raise ValueError("one semantic unit exceeds the advertised embedding request ceiling")
+ rows = rows[:selected_count]
+ texts = texts[:selected_count]
+ metadata = metadata[:selected_count]
+ attributions = attributions[:selected_count]
+
vectors = await asyncio.to_thread(
embedding_client.embed_many,
texts,
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index 770c142ed..7a3c90457 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -16,7 +16,7 @@
from typing import Protocol
from .chunking import Chunk, chunk_by_paragraph
-from .http_client import get_json, post_json
+from .http_client import get_json, json_request_body, post_json
class EmbeddingClient(Protocol):
@@ -102,17 +102,11 @@ def embed_many(
if input_metadata is not None and len(input_metadata) != len(texts):
raise ValueError("input_metadata must align with texts")
headers = {"authorization": f"Bearer {self._api_key}"}
- payload = {
- "inputs": texts,
- "endpoint": "/v1/embeddings",
- "metadata": {"service": "lineageweave", "channel": "post_content_embedding"},
- }
- if input_attributions is not None:
- payload["input_attributions"] = [dict(value) for value in input_attributions]
- if input_metadata is not None:
- payload["input_metadata"] = [dict(value) for value in input_metadata]
- if self._model is not None:
- payload["model"] = self._model
+ payload = self.batch_payload(
+ texts,
+ input_attributions=input_attributions,
+ input_metadata=input_metadata,
+ )
response = post_json(
f"{self._base_url}/batch/embeddings",
payload,
@@ -145,6 +139,59 @@ def embed_many(
raise ValueError("embedding response did not contain a complete vector batch")
return vectors
+ def batch_payload(
+ self,
+ texts: list[str],
+ *,
+ input_attributions: list[Mapping[str, object]] | None = None,
+ input_metadata: list[Mapping[str, object]] | None = None,
+ ) -> dict[str, object]:
+ """Build the exact provider-neutral bulk request document."""
+ payload: dict[str, object] = {
+ "inputs": texts,
+ "endpoint": "/v1/embeddings",
+ "metadata": {"service": "lineageweave", "channel": "post_content_embedding"},
+ }
+ if input_attributions is not None:
+ payload["input_attributions"] = [dict(value) for value in input_attributions]
+ if input_metadata is not None:
+ payload["input_metadata"] = [dict(value) for value in input_metadata]
+ if self._model is not None:
+ payload["model"] = self._model
+ return payload
+
+ def batch_request_body_size(
+ self,
+ texts: list[str],
+ *,
+ input_attributions: list[Mapping[str, object]] | None = None,
+ input_metadata: list[Mapping[str, object]] | None = None,
+ ) -> int:
+ """Return exact UTF-8 bytes sent for one bulk request."""
+ return len(
+ json_request_body(
+ self.batch_payload(
+ texts,
+ input_attributions=input_attributions,
+ input_metadata=input_metadata,
+ )
+ )
+ )
+
+ def batch_capabilities(self) -> dict[str, int]:
+ """Read enforced bulk request ceilings from contextual-orchestrator."""
+ headers = {"authorization": f"Bearer {self._api_key}"}
+ response = get_json(
+ f"{self._base_url}/batch/embeddings/capabilities",
+ headers=headers,
+ timeout=self._timeout,
+ service_peer_name="contextual-orchestrator",
+ )
+ required = ("max_request_body_bytes", "max_tokens_per_part", "max_chars_per_part")
+ if any(type(response.get(key)) is not int or response[key] < 1 for key in required):
+ raise ValueError("embedding batch capabilities are incomplete")
+ return {key: int(response[key]) for key in required}
+
@property
def resolved_model(self) -> str | None:
"""Return the provider-neutral model identity selected upstream."""
diff --git a/scripts/backfill_post_embeddings.py b/scripts/backfill_post_embeddings.py
index 48a36bad8..f713183f7 100755
--- a/scripts/backfill_post_embeddings.py
+++ b/scripts/backfill_post_embeddings.py
@@ -22,7 +22,6 @@
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--limit", required=True, type=int)
parser.add_argument(
"--target-dsn",
default=os.environ.get(
@@ -33,17 +32,20 @@ def _parser() -> argparse.ArgumentParser:
return parser
-async def _run(target_dsn: str, input_limit: int) -> dict[str, int | str]:
+async def _run(target_dsn: str) -> dict[str, int | str]:
client = orchestrator_embedding_client(
os.environ.get("ORCHESTRATOR_BASE_URL", ""),
os.environ.get("ORCHESTRATOR_API_KEY", ""),
)
if not client.available:
raise RuntimeError("embedding is unavailable; configure contextual-orchestrator")
+ capabilities = client.batch_capabilities()
conn = await asyncpg.connect(target_dsn)
try:
return await backfill_post_content_embeddings(
- conn, client, input_limit=input_limit
+ conn,
+ client,
+ max_request_body_bytes=capabilities["max_request_body_bytes"],
)
finally:
await conn.close()
@@ -52,9 +54,7 @@ async def _run(target_dsn: str, input_limit: int) -> dict[str, int | str]:
def main() -> None:
"""Run one operator-bounded embedding batch and print aggregate counts only."""
args = _parser().parse_args()
- if args.limit < 1:
- raise SystemExit("--limit must be positive")
- print(json.dumps(asyncio.run(_run(args.target_dsn, args.limit)), sort_keys=True))
+ print(json.dumps(asyncio.run(_run(args.target_dsn)), sort_keys=True))
if __name__ == "__main__":
diff --git a/tests/test_embedding_backfill.py b/tests/test_embedding_backfill.py
index 01f574e91..3e84b7668 100644
--- a/tests/test_embedding_backfill.py
+++ b/tests/test_embedding_backfill.py
@@ -34,12 +34,14 @@ def __init__(self, rows):
async def fetch(self, query, *args):
if "from post_content_unit unit" in query:
return self.rows
+ selected_unit_ids = set(args[1])
return [
{
"post_content_unit_id": unit_id,
"post_content_embedding_id": embedding_id,
}
for unit_id, embedding_id in self.embedding_ids.items()
+ if unit_id in selected_unit_ids
]
def transaction(self):
@@ -67,6 +69,9 @@ def embed_many(self, texts, **kwargs):
self.resolved_model = "synthetic-embedding-model"
return [[float(index), 1.0] for index, _text in enumerate(texts)]
+ def batch_request_body_size(self, texts, **kwargs):
+ return sum(len(text.encode("utf-8")) for text in texts) + 100 * len(texts)
+
def _row(index: int) -> dict[str, object]:
return {
@@ -90,7 +95,9 @@ def test_bulk_backfill_calls_provider_once_and_persists_in_one_transaction() ->
conn = _Connection(rows)
client = _EmbeddingClient()
- result = asyncio.run(backfill_post_content_embeddings(conn, client, input_limit=2))
+ result = asyncio.run(
+ backfill_post_content_embeddings(conn, client, max_request_body_bytes=10_000)
+ )
assert result == {
"selected_units": 2,
@@ -115,7 +122,9 @@ def test_provider_failure_makes_no_database_change() -> None:
client = _EmbeddingClient(fail=True)
with pytest.raises(RuntimeError, match="synthetic provider failure"):
- asyncio.run(backfill_post_content_embeddings(conn, client, input_limit=2))
+ asyncio.run(
+ backfill_post_content_embeddings(conn, client, max_request_body_bytes=10_000)
+ )
assert conn.transaction_entries == 0
assert conn.executemany_calls == []
@@ -126,7 +135,9 @@ def test_empty_selection_skips_provider_and_transaction() -> None:
conn = _Connection([])
client = _EmbeddingClient()
- result = asyncio.run(backfill_post_content_embeddings(conn, client, input_limit=1))
+ result = asyncio.run(
+ backfill_post_content_embeddings(conn, client, max_request_body_bytes=10_000)
+ )
assert result == {
"selected_units": 0,
@@ -135,3 +146,21 @@ def test_empty_selection_skips_provider_and_transaction() -> None:
}
assert client.calls == []
assert conn.transaction_entries == 0
+
+
+def test_bulk_backfill_packs_largest_prefix_within_advertised_body_ceiling() -> None:
+ rows = [_row(0), _row(1), _row(2)]
+ conn = _Connection(rows)
+ client = _EmbeddingClient()
+ two_input_size = client.batch_request_body_size(
+ [str(rows[0]["unit_text"]), str(rows[1]["unit_text"])]
+ )
+
+ result = asyncio.run(
+ backfill_post_content_embeddings(
+ conn, client, max_request_body_bytes=two_input_size
+ )
+ )
+
+ assert result["selected_units"] == 2
+ assert len(client.calls[0][0]) == 2
diff --git a/tests/test_embedding_client_edges.py b/tests/test_embedding_client_edges.py
index 2fa205429..0c7dfd53e 100644
--- a/tests/test_embedding_client_edges.py
+++ b/tests/test_embedding_client_edges.py
@@ -29,6 +29,28 @@ def test_per_input_context_must_align_with_texts(field: str) -> None:
client.embed_many(["first", "second"], **{field: [{"key": "value"}]})
+def test_batch_capabilities_require_positive_integer_limits(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ embedding_client,
+ "get_json",
+ lambda *_args, **_kwargs: {
+ "max_request_body_bytes": 65_536,
+ "max_tokens_per_part": 280_000,
+ "max_chars_per_part": 240_000,
+ },
+ )
+ client = embedding_client.ContextualOrchestratorEmbeddingClient(
+ "http://orchestrator", "key"
+ )
+ assert client.batch_capabilities()["max_request_body_bytes"] == 65_536
+
+ monkeypatch.setattr(embedding_client, "get_json", lambda *_args, **_kwargs: {})
+ with pytest.raises(ValueError, match="capabilities are incomplete"):
+ client.batch_capabilities()
+
+
def test_immediate_embedding_response_is_ordered(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
embedding_client,
From bcfb3e3c60dd25c531d60680432c46b13ae92fb3 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:32:03 +0900
Subject: [PATCH 119/393] build(runtime): pin advertised embedding limits
---
docker/contextual-orchestrator/Dockerfile | 2 +-
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index d2ab83d29..4dd7f5354 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/d9c62be9feea24fdaeb8453f3c72f2c2b0237143.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/ffe036e983575d10f806a95ee3e0e3b0f46dcdeb.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index bf0937b5b..d7595cf93 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `d9c62be9feea24fdaeb8453f3c72f2c2b0237143`. The pin remains explicit
+commit `ffe036e983575d10f806a95ee3e0e3b0f46dcdeb`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
From 111cbcdcd94692547e64abcfb55b96016d92fd46 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:34:19 +0900
Subject: [PATCH 120/393] fix(runtime): separate API and durable queue workers
---
backend/app/main.py | 60 +----------------
backend/app/worker.py | 84 ++++++++++++++++++++++++
docker-compose.yml | 26 +++++++-
tests/test_backend_worker_process.py | 97 ++++++++++++++++++++++++++++
4 files changed, 208 insertions(+), 59 deletions(-)
create mode 100644 backend/app/worker.py
create mode 100644 tests/test_backend_worker_process.py
diff --git a/backend/app/main.py b/backend/app/main.py
index efd8494eb..1b7f407be 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -65,7 +65,6 @@
deliver_queued_analysis_run,
enqueue_pending_analysis_run,
)
-from backend.app.analysis_run_worker import run_analysis_run_worker
from backend.app.auth import CurrentAccount, get_current_account
from backend.app.config import load_settings
from backend.app.customer_hint_ingestion import resolve_customer_hint
@@ -80,10 +79,7 @@
ingest_post_entity_relationships,
)
from backend.app.five_w1h_ingestion import load_five_w1h_slots
-from backend.app.global_ask_queue import (
- enqueue_global_ask_job,
- run_global_ask_worker,
-)
+from backend.app.global_ask_queue import enqueue_global_ask_job
from backend.app.issue_ticket_ingestion import (
create_ticket,
fetch_ticket_post_id,
@@ -137,7 +133,6 @@
post_content_is_complete,
publish_post_content_event,
)
-from backend.app.post_content_worker import run_post_content_worker
from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from backend.app.post_evaluation_ingestion import (
fetch_post_evaluation,
@@ -247,69 +242,18 @@
@asynccontextmanager
async def lifespan(app: FastAPI):
- """Open one asyncpg pool and one Valkey client for the process, and
- close both on shutdown."""
+ """Open API database and Valkey clients without consuming durable jobs."""
configure_telemetry("lineageweave")
pool = None
valkey = None
- analysis_worker = None
- content_worker = None
- global_ask_worker = None
try:
settings = load_settings()
pool = await create_pool(settings.database_url)
app.state.pool = pool
valkey = create_valkey_client(settings.valkey_url)
app.state.valkey = valkey
- analysis_worker = asyncio.create_task(
- run_analysis_run_worker(
- valkey,
- pool,
- database_url=settings.database_url,
- tepp_client=configured_tepp_client(
- settings.tepp_transport_url,
- settings.tepp_api_key,
- ),
- adjudication_client=_adjudication_client(),
- )
- )
- app.state.analysis_run_worker = analysis_worker
- content_worker = asyncio.create_task(
- run_post_content_worker(
- valkey,
- pool,
- vision_factory=_vision_client,
- embedding_factory=_embedding_client,
- structure_factory=_post_structure_client,
- )
- )
- app.state.post_content_worker = content_worker
- # Late-bound lambda so tests that monkeypatch _post_chat_client reach
- # the worker too (the name resolves in module globals at call time).
- # Only this worker gets the long answer timeout; the per-post chat
- # endpoint keeps the client's interactive default.
- global_ask_worker = asyncio.create_task(
- run_global_ask_worker(
- valkey,
- pool,
- chat_factory=lambda: _post_chat_client(
- timeout=load_settings().orchestrator_answer_timeout_seconds
- ),
- embedding_factory=_embedding_client,
- )
- )
- app.state.global_ask_worker = global_ask_worker
yield
finally:
- workers = tuple(
- worker
- for worker in (analysis_worker, content_worker, global_ask_worker)
- if worker is not None
- )
- for worker in workers:
- worker.cancel()
- if workers:
- await asyncio.gather(*workers, return_exceptions=True)
try:
if pool is not None:
await pool.close()
diff --git a/backend/app/worker.py b/backend/app/worker.py
new file mode 100644
index 000000000..d627f492e
--- /dev/null
+++ b/backend/app/worker.py
@@ -0,0 +1,84 @@
+"""Dedicated durable-queue worker process for the Compose deployment."""
+
+from __future__ import annotations
+
+import asyncio
+
+from backend.app.activity_stream import create_valkey_client
+from backend.app.analysis_run_start import configured_tepp_client
+from backend.app.analysis_run_worker import run_analysis_run_worker
+from backend.app.config import load_settings
+from backend.app.db import create_pool
+from backend.app.global_ask_queue import run_global_ask_worker
+from backend.app.main import (
+ _adjudication_client,
+ _embedding_client,
+ _post_chat_client,
+ _post_structure_client,
+ _vision_client,
+)
+from backend.app.post_content_worker import run_post_content_worker
+from lineageweave.observability import configure_telemetry, shutdown_telemetry
+
+
+async def run_worker_process() -> None:
+ """Own every durable queue consumer outside the HTTP API process."""
+ configure_telemetry("lineageweave-worker")
+ settings = load_settings()
+ pool = await create_pool(settings.database_url)
+ valkey = create_valkey_client(settings.valkey_url)
+ workers = (
+ asyncio.create_task(
+ run_analysis_run_worker(
+ valkey,
+ pool,
+ database_url=settings.database_url,
+ tepp_client=configured_tepp_client(
+ settings.tepp_transport_url,
+ settings.tepp_api_key,
+ ),
+ adjudication_client=_adjudication_client(),
+ )
+ ),
+ asyncio.create_task(
+ run_post_content_worker(
+ valkey,
+ pool,
+ vision_factory=_vision_client,
+ embedding_factory=_embedding_client,
+ structure_factory=_post_structure_client,
+ )
+ ),
+ asyncio.create_task(
+ run_global_ask_worker(
+ valkey,
+ pool,
+ chat_factory=lambda: _post_chat_client(
+ timeout=load_settings().orchestrator_answer_timeout_seconds
+ ),
+ embedding_factory=_embedding_client,
+ )
+ ),
+ )
+ try:
+ await asyncio.gather(*workers)
+ finally:
+ for worker in workers:
+ worker.cancel()
+ await asyncio.gather(*workers, return_exceptions=True)
+ try:
+ await pool.close()
+ finally:
+ try:
+ await valkey.aclose()
+ finally:
+ shutdown_telemetry()
+
+
+def main() -> None:
+ """Run the durable worker service until Compose stops the process."""
+ asyncio.run(run_worker_process())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docker-compose.yml b/docker-compose.yml
index 6729174f6..7a7c401d9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -142,7 +142,7 @@ services:
build:
context: .
dockerfile: backend/Dockerfile
- environment:
+ environment: &backend-environment
DATABASE_URL: postgresql://${POSTGRES_USER:-lineageweave}:${POSTGRES_PASSWORD:-lineageweave_dev_only}@postgres:5432/${POSTGRES_DB:-lineageweave}
# Internal DNS name for JWKS fetches (always reachable from inside the
# compose network); KEYCLOAK_ISSUER is the *external*, host-published
@@ -201,6 +201,30 @@ services:
searxng:
condition: service_healthy
+ backend-worker:
+ build:
+ context: .
+ dockerfile: backend/Dockerfile
+ command: ["python", "-m", "backend.app.worker"]
+ environment: *backend-environment
+ depends_on:
+ postgres:
+ condition: service_healthy
+ database_migration:
+ condition: service_completed_successfully
+ orchestrator:
+ condition: service_healthy
+ valkey:
+ condition: service_healthy
+ searxng:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "kill -0 1"]
+ interval: 10s
+ timeout: 3s
+ retries: 3
+ start_period: 5s
+
frontend:
build:
context: ./frontend
diff --git a/tests/test_backend_worker_process.py b/tests/test_backend_worker_process.py
new file mode 100644
index 000000000..a03e24c61
--- /dev/null
+++ b/tests/test_backend_worker_process.py
@@ -0,0 +1,97 @@
+"""Process-ownership tests for API and durable queue consumers."""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+
+from backend.app import main, worker
+
+
+class _Closable:
+ def __init__(self) -> None:
+ self.closed = False
+
+ async def close(self) -> None:
+ self.closed = True
+
+ async def aclose(self) -> None:
+ self.closed = True
+
+
+def test_api_lifespan_opens_clients_without_starting_queue_workers(monkeypatch) -> None:
+ """Serving HTTP never competes with the dedicated durable worker service."""
+ pool = _Closable()
+ valkey = _Closable()
+ monkeypatch.setattr(
+ main,
+ "load_settings",
+ lambda: SimpleNamespace(database_url="db", valkey_url="valkey"),
+ )
+ monkeypatch.setattr(main, "create_pool", lambda _url: _async_value(pool))
+ monkeypatch.setattr(main, "create_valkey_client", lambda _url: valkey)
+ monkeypatch.setattr(main, "configure_telemetry", lambda _name: None)
+ monkeypatch.setattr(main, "shutdown_telemetry", lambda: None)
+ app = SimpleNamespace(state=SimpleNamespace())
+
+ async def exercise() -> None:
+ async with main.lifespan(app):
+ assert app.state.pool is pool
+ assert app.state.valkey is valkey
+ assert not hasattr(app.state, "post_content_worker")
+ assert not hasattr(app.state, "analysis_run_worker")
+ assert not hasattr(app.state, "global_ask_worker")
+
+ asyncio.run(exercise())
+ assert pool.closed
+ assert valkey.closed
+
+
+def test_worker_process_owns_all_three_durable_consumers(monkeypatch) -> None:
+ """Analysis, post-content, and Global Ask queues share one worker owner."""
+ pool = _Closable()
+ valkey = _Closable()
+ calls: list[str] = []
+ settings = SimpleNamespace(
+ database_url="db",
+ valkey_url="valkey",
+ tepp_transport_url="",
+ tepp_api_key="",
+ orchestrator_answer_timeout_seconds=570.0,
+ )
+
+ async def called(name: str, *_args, **_kwargs) -> None:
+ calls.append(name)
+
+ monkeypatch.setattr(worker, "load_settings", lambda: settings)
+ monkeypatch.setattr(worker, "create_pool", lambda _url: _async_value(pool))
+ monkeypatch.setattr(worker, "create_valkey_client", lambda _url: valkey)
+ monkeypatch.setattr(worker, "configure_telemetry", lambda _name: None)
+ monkeypatch.setattr(worker, "shutdown_telemetry", lambda: calls.append("shutdown"))
+ monkeypatch.setattr(worker, "configured_tepp_client", lambda *_args: object())
+ monkeypatch.setattr(worker, "_adjudication_client", lambda: object())
+ monkeypatch.setattr(worker, "_vision_client", lambda: object())
+ monkeypatch.setattr(worker, "_embedding_client", lambda: object())
+ monkeypatch.setattr(worker, "_post_structure_client", lambda: object())
+ monkeypatch.setattr(worker, "_post_chat_client", lambda **_kwargs: object())
+ monkeypatch.setattr(
+ worker, "run_analysis_run_worker", lambda *a, **kw: called("analysis", *a, **kw)
+ )
+ monkeypatch.setattr(
+ worker, "run_post_content_worker", lambda *a, **kw: called("content", *a, **kw)
+ )
+ monkeypatch.setattr(
+ worker, "run_global_ask_worker", lambda *a, **kw: called("global_ask", *a, **kw)
+ )
+
+ asyncio.run(worker.run_worker_process())
+
+ assert calls[:3] == ["analysis", "content", "global_ask"]
+ assert calls[-1] == "shutdown"
+ assert pool.closed
+ assert valkey.closed
+
+
+async def _async_value(value):
+ """Return one test double through an awaitable seam."""
+ return value
From 8049468f99adc5af29aa4e2774aabbff865fbd47 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:36:20 +0900
Subject: [PATCH 121/393] fix(runtime): restart dedicated worker after process
failure
---
docker-compose.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/docker-compose.yml b/docker-compose.yml
index 7a7c401d9..27a783fe3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -206,6 +206,7 @@ services:
context: .
dockerfile: backend/Dockerfile
command: ["python", "-m", "backend.app.worker"]
+ restart: unless-stopped
environment: *backend-environment
depends_on:
postgres:
From 3a41e31b0a84f46a8356727e3bca3cd0da018bec Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:37:55 +0900
Subject: [PATCH 122/393] build(runtime): pin request-deadline failover
---
docker/contextual-orchestrator/Dockerfile | 2 +-
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 4dd7f5354..4ddf5f332 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/ffe036e983575d10f806a95ee3e0e3b0f46dcdeb.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/a4e71d3e33e323c4b3decc66a1e0163fa6017cff.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index d7595cf93..34529bb65 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `ffe036e983575d10f806a95ee3e0e3b0f46dcdeb`. The pin remains explicit
+commit `a4e71d3e33e323c4b3decc66a1e0163fa6017cff`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
From c6d8b2f670ab9981f8ed36d300938d36928efb45 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:39:03 +0900
Subject: [PATCH 123/393] fix(operations): send exact request deadline
---
lineageweave/operations_case_analysis.py | 5 ++++-
tests/test_operations_case_analysis.py | 7 ++++++-
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py
index 96df33bf1..184e70b92 100644
--- a/lineageweave/operations_case_analysis.py
+++ b/lineageweave/operations_case_analysis.py
@@ -419,7 +419,10 @@ def analyze(
"mode": "auto",
"reasoning_effort": "auto",
},
- headers={"authorization": f"Bearer {self._api_key}"},
+ headers={
+ "authorization": f"Bearer {self._api_key}",
+ "x-request-timeout-ms": str(round(self._timeout * 1000)),
+ },
timeout=self._timeout,
)
parsed = parse_operations_case_response(
diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py
index 8f4d6e9e7..6ffe9d249 100644
--- a/tests/test_operations_case_analysis.py
+++ b/tests/test_operations_case_analysis.py
@@ -15,8 +15,11 @@ def test_orchestrator_request_uses_provider_neutral_auto_selector(monkeypatch) -
"""The consumer selects orchestrator routing, never a provider model name."""
captured: dict[str, object] = {}
- def post_json(_url, payload, **_kwargs):
+ captured_request: dict[str, object] = {}
+
+ def post_json(_url, payload, **kwargs):
captured.update(payload)
+ captured_request.update(kwargs)
return {"choices": [{"message": {"content": "[]"}}]}
monkeypatch.setattr(operations_case_analysis, "post_json", post_json)
@@ -27,6 +30,8 @@ def post_json(_url, payload, **_kwargs):
"",
) == ()
assert captured["model"] == "orchestrator/auto"
+ assert captured_request["timeout"] == 180.0
+ assert captured_request["headers"]["x-request-timeout-ms"] == "180000"
def test_analysis_input_digest_tracks_ordered_evidence_and_context() -> None:
From 311459da85fed72eba90e3b48a58a0a348f36683 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 13:59:09 +0900
Subject: [PATCH 124/393] fix(embeddings): honor orchestrator polling cadence
---
docker/contextual-orchestrator/Dockerfile | 2 +-
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
lineageweave/embedding_client.py | 12 ++++++++++--
tests/test_embedding_client.py | 7 ++++++-
tests/test_embedding_client_edges.py | 7 ++++++-
5 files changed, 24 insertions(+), 6 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 4ddf5f332..9ac01dc94 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/a4e71d3e33e323c4b3decc66a1e0163fa6017cff.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/b6cf8d5c77756b36e992924667d07b1c501a9d62.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 34529bb65..93e24a0ca 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `a4e71d3e33e323c4b3decc66a1e0163fa6017cff`. The pin remains explicit
+commit `b6cf8d5c77756b36e992924667d07b1c501a9d62`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index 7a3c90457..dbafe9b91 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -125,7 +125,10 @@ def embed_many(
raise RuntimeError("embedding batch did not complete")
if time.monotonic() >= deadline:
raise TimeoutError("embedding batch timed out")
- time.sleep(self._poll_interval)
+ poll_after_ms = response.get("poll_after_ms")
+ if type(poll_after_ms) is not int or poll_after_ms < 1:
+ raise ValueError("embedding batch did not declare a polling cadence")
+ time.sleep(poll_after_ms / 1000)
response = get_json(
f"{self._base_url}/batch/embeddings/{batch_id}",
headers=headers,
@@ -187,7 +190,12 @@ def batch_capabilities(self) -> dict[str, int]:
timeout=self._timeout,
service_peer_name="contextual-orchestrator",
)
- required = ("max_request_body_bytes", "max_tokens_per_part", "max_chars_per_part")
+ required = (
+ "max_request_body_bytes",
+ "max_tokens_per_part",
+ "max_chars_per_part",
+ "poll_after_ms",
+ )
if any(type(response.get(key)) is not int or response[key] < 1 for key in required):
raise ValueError("embedding batch capabilities are incomplete")
return {key: int(response[key]) for key in required}
diff --git a/tests/test_embedding_client.py b/tests/test_embedding_client.py
index a604e5330..3576d5ebd 100644
--- a/tests/test_embedding_client.py
+++ b/tests/test_embedding_client.py
@@ -94,7 +94,12 @@ def test_orchestrator_embedding_client_submits_and_polls_batch(monkeypatch) -> N
def fake_post_json(url, payload, *, headers, timeout):
calls.append(("post", url, payload, headers))
- return {"batch_id": "synthetic-batch", "status": "queued", "model": "resolved-embedding"}
+ return {
+ "batch_id": "synthetic-batch",
+ "status": "queued",
+ "model": "resolved-embedding",
+ "poll_after_ms": 1,
+ }
def fake_get_json(url, *, headers, timeout, service_peer_name):
assert service_peer_name == "contextual-orchestrator"
diff --git a/tests/test_embedding_client_edges.py b/tests/test_embedding_client_edges.py
index 0c7dfd53e..045eb2561 100644
--- a/tests/test_embedding_client_edges.py
+++ b/tests/test_embedding_client_edges.py
@@ -39,6 +39,7 @@ def test_batch_capabilities_require_positive_integer_limits(
"max_request_body_bytes": 65_536,
"max_tokens_per_part": 280_000,
"max_chars_per_part": 240_000,
+ "poll_after_ms": 1_000,
},
)
client = embedding_client.ContextualOrchestratorEmbeddingClient(
@@ -68,7 +69,9 @@ def test_immediate_embedding_response_is_ordered(monkeypatch: pytest.MonkeyPatch
def test_batch_response_polls_until_complete(monkeypatch: pytest.MonkeyPatch) -> None:
- responses = iter([{"batch_id": "batch-1", "status": "pending", "model": "model"}])
+ responses = iter([
+ {"batch_id": "batch-1", "status": "pending", "model": "model", "poll_after_ms": 1_000}
+ ])
monkeypatch.setattr(embedding_client, "post_json", lambda *_args, **_kwargs: next(responses))
monkeypatch.setattr(
embedding_client,
@@ -92,6 +95,7 @@ def test_failed_batch_raises_without_fallback(monkeypatch: pytest.MonkeyPatch) -
"batch_id": "batch-1",
"status": "failed",
"model": "model",
+ "poll_after_ms": 1_000,
},
)
client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", "model")
@@ -107,6 +111,7 @@ def test_batch_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None:
"batch_id": "batch-1",
"status": "pending",
"model": "model",
+ "poll_after_ms": 1_000,
},
)
monkeypatch.setattr(embedding_client.time, "monotonic", iter([0.0, 2.0]).__next__)
From 0271499b407c4dd7634fcf6fbd7af5a626dce282 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 14:02:46 +0900
Subject: [PATCH 125/393] fix(embeddings): wait within durable batch contract
---
docker/contextual-orchestrator/Dockerfile | 2 +-
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
lineageweave/embedding_client.py | 6 +++++-
tests/test_embedding_client.py | 1 +
tests/test_embedding_client_edges.py | 11 ++++++++++-
5 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 9ac01dc94..e094b4933 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/b6cf8d5c77756b36e992924667d07b1c501a9d62.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/2f3feb9397672fd4727c3138eefdc2b97390e42d.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 93e24a0ca..dd4efece4 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `b6cf8d5c77756b36e992924667d07b1c501a9d62`. The pin remains explicit
+commit `2f3feb9397672fd4727c3138eefdc2b97390e42d`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index dbafe9b91..632c33e1d 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -116,7 +116,10 @@ def embed_many(
self._bind_model(response)
batch_id = response.get("batch_id")
if isinstance(batch_id, str) and batch_id:
- deadline = time.monotonic() + self._timeout
+ job_retention_ms = response.get("job_retention_ms")
+ if type(job_retention_ms) is not int or job_retention_ms < 1:
+ raise ValueError("embedding batch did not declare result retention")
+ deadline = time.monotonic() + job_retention_ms / 1000
while True:
vectors = self._vectors(response, len(texts))
if vectors is not None:
@@ -195,6 +198,7 @@ def batch_capabilities(self) -> dict[str, int]:
"max_tokens_per_part",
"max_chars_per_part",
"poll_after_ms",
+ "job_retention_ms",
)
if any(type(response.get(key)) is not int or response[key] < 1 for key in required):
raise ValueError("embedding batch capabilities are incomplete")
diff --git a/tests/test_embedding_client.py b/tests/test_embedding_client.py
index 3576d5ebd..1e8c23e2e 100644
--- a/tests/test_embedding_client.py
+++ b/tests/test_embedding_client.py
@@ -99,6 +99,7 @@ def fake_post_json(url, payload, *, headers, timeout):
"status": "queued",
"model": "resolved-embedding",
"poll_after_ms": 1,
+ "job_retention_ms": 60_000,
}
def fake_get_json(url, *, headers, timeout, service_peer_name):
diff --git a/tests/test_embedding_client_edges.py b/tests/test_embedding_client_edges.py
index 045eb2561..c08151b43 100644
--- a/tests/test_embedding_client_edges.py
+++ b/tests/test_embedding_client_edges.py
@@ -40,6 +40,7 @@ def test_batch_capabilities_require_positive_integer_limits(
"max_tokens_per_part": 280_000,
"max_chars_per_part": 240_000,
"poll_after_ms": 1_000,
+ "job_retention_ms": 60_000,
},
)
client = embedding_client.ContextualOrchestratorEmbeddingClient(
@@ -70,7 +71,13 @@ def test_immediate_embedding_response_is_ordered(monkeypatch: pytest.MonkeyPatch
def test_batch_response_polls_until_complete(monkeypatch: pytest.MonkeyPatch) -> None:
responses = iter([
- {"batch_id": "batch-1", "status": "pending", "model": "model", "poll_after_ms": 1_000}
+ {
+ "batch_id": "batch-1",
+ "status": "pending",
+ "model": "model",
+ "poll_after_ms": 1_000,
+ "job_retention_ms": 60_000,
+ }
])
monkeypatch.setattr(embedding_client, "post_json", lambda *_args, **_kwargs: next(responses))
monkeypatch.setattr(
@@ -96,6 +103,7 @@ def test_failed_batch_raises_without_fallback(monkeypatch: pytest.MonkeyPatch) -
"status": "failed",
"model": "model",
"poll_after_ms": 1_000,
+ "job_retention_ms": 60_000,
},
)
client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", "model")
@@ -112,6 +120,7 @@ def test_batch_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None:
"status": "pending",
"model": "model",
"poll_after_ms": 1_000,
+ "job_retention_ms": 1_000,
},
)
monkeypatch.setattr(embedding_client.time, "monotonic", iter([0.0, 2.0]).__next__)
From c5fc9b50681f18c028e83b2851b3531d2032bd02 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 14:18:12 +0900
Subject: [PATCH 126/393] build(embeddings): pin true provider bulk runtime
---
docker/contextual-orchestrator/Dockerfile | 2 +-
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index e094b4933..090e1852a 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/2f3feb9397672fd4727c3138eefdc2b97390e42d.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/4fc7e417e636b246fbbbb2b215af2a7d6bd3ea65.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index dd4efece4..af3fd25f3 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `2f3feb9397672fd4727c3138eefdc2b97390e42d`. The pin remains explicit
+commit `4fc7e417e636b246fbbbb2b215af2a7d6bd3ea65`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
From cbc63bd95d49a030a3bb1e5ca7b03b098df44f44 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 14:21:29 +0900
Subject: [PATCH 127/393] feat(ontology): add evidence-bound product catalog
---
backend/app/main.py | 23 +++
backend/app/post_content_worker.py | 67 +++++++
backend/app/product_semantic_ingestion.py | 83 +++++++++
backend/tests/test_api.py | 63 +++++++
.../tests/test_product_semantic_ingestion.py | 68 +++++++
...evidence-bound-product-semantic-catalog.md | 84 +++++++++
docs/adr/README.md | 1 +
docs/ontology/lineageweave-kg-shapes.ttl | 24 +++
docs/ontology/lineageweave-kg.ttl | 20 +++
docs/product-requirements.md | 15 +-
docs/product-technical-gap-baseline.md | 1 +
docs/storybook-inventory.md | 1 +
frontend/src/App.tsx | 2 +
frontend/src/api.ts | 11 ++
.../ProductEvidenceList.stories.tsx | 38 ++++
.../components/ProductEvidenceList.test.tsx | 18 ++
.../src/components/ProductEvidenceList.tsx | 23 +++
frontend/src/i18n.ts | 8 +
lineageweave/product_semantics.py | 166 ++++++++++++++++++
migrations/0228_product_semantic_catalog.sql | 89 ++++++++++
tests/test_post_content_worker.py | 80 +++++++++
tests/test_product_semantics.py | 115 ++++++++++++
22 files changed, 999 insertions(+), 1 deletion(-)
create mode 100644 backend/app/product_semantic_ingestion.py
create mode 100644 backend/tests/test_product_semantic_ingestion.py
create mode 100644 docs/adr/0228-evidence-bound-product-semantic-catalog.md
create mode 100644 frontend/src/components/ProductEvidenceList.stories.tsx
create mode 100644 frontend/src/components/ProductEvidenceList.test.tsx
create mode 100644 frontend/src/components/ProductEvidenceList.tsx
create mode 100644 lineageweave/product_semantics.py
create mode 100644 migrations/0228_product_semantic_catalog.sql
create mode 100644 tests/test_product_semantics.py
diff --git a/backend/app/main.py b/backend/app/main.py
index 1b7f407be..ec0daadde 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1590,6 +1590,17 @@ async def read_post(
project_evidence = await _load_project_evidence(
conn, post_id, row["source_project_code"], row["source_project_name"]
)
+ product_rows = await conn.fetch(
+ "select mention.mention_ordinal, mention.extracted_product_name, "
+ "mention.resolution_status_code, catalog.canonical_product_name, "
+ "catalog.product_level_code, mention.evidence_text, "
+ "mention.evidence_post_id "
+ "from post_product_mention mention "
+ "left join product_catalog catalog "
+ "on catalog.product_catalog_id = mention.product_catalog_id "
+ "where mention.post_id = $1 order by mention.mention_ordinal",
+ post_id,
+ )
known_at = None
if as_of_clock is not None:
known_at = await fetch_known_at_revision(conn, post_id, as_of_clock)
@@ -1597,6 +1608,18 @@ async def read_post(
**_serialize_post(row, labels),
"post_body": row["post_body"],
"project_evidence": project_evidence,
+ "product_evidence": [
+ {
+ "mention_ordinal": item["mention_ordinal"],
+ "extracted_product_name": item["extracted_product_name"],
+ "resolution_status_code": item["resolution_status_code"],
+ "canonical_product_name": item["canonical_product_name"],
+ "product_level_code": item["product_level_code"],
+ "evidence_text": item["evidence_text"],
+ "evidence_post_id": item["evidence_post_id"],
+ }
+ for item in product_rows
+ ],
}
if known_at is not None:
payload["known_at"] = known_at
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index daf46fee3..707fa3a94 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -27,6 +27,10 @@
transition_post_content_job,
)
from backend.app.operations_case_ingestion import persist_operations_cases
+from backend.app.product_semantic_ingestion import (
+ persist_product_mentions,
+ resolve_product_mentions,
+)
from backend.app.post_chat_ingestion import (
find_project_sibling_post_ids,
gather_chat_sources,
@@ -44,6 +48,11 @@
from lineageweave.post_content_normalization import normalize_post_body
from lineageweave.post_content_persistence import persist_post_content
from lineageweave.post_structure import PostStructureClient
+from lineageweave.product_semantics import (
+ ContextualOrchestratorProductExtractionClient,
+ ProductEvidenceSource,
+ product_analysis_input_sha256,
+)
_logger = logging.getLogger(__name__)
_RECOVERY_INTERVAL_SECONDS = 30.0
@@ -169,6 +178,54 @@ async def _persist_operations_case_analysis_if_needed(
)
+async def _persist_product_analysis_if_needed(
+ pool: asyncpg.Pool,
+ post_id: str,
+ source_body_digest: str,
+ row: asyncpg.Record,
+ vision_client: ImageContentClient,
+ session_id: str,
+ orchestrator_base_url: str,
+ orchestrator_api_key: str,
+) -> None:
+ """Extract and persist products once per exact authorized source window."""
+ operation_sources = await _operations_evidence_sources(
+ pool, post_id, row, vision_client
+ )
+ sources = tuple(
+ ProductEvidenceSource(source.post_id, source.text)
+ for source in operation_sources
+ )
+ input_digest = product_analysis_input_sha256(sources)
+ async with pool.acquire() as conn:
+ already_persisted = bool(
+ await conn.fetchval(
+ "select exists (select 1 from post_product_analysis "
+ "where post_id = $1 and source_body_sha256 = $2 "
+ "and analysis_input_sha256 = $3)",
+ post_id,
+ source_body_digest,
+ input_digest,
+ )
+ )
+ if already_persisted:
+ return
+ client = ContextualOrchestratorProductExtractionClient(
+ orchestrator_base_url, orchestrator_api_key
+ )
+ mentions = await asyncio.to_thread(client.extract, sources)
+ async with pool.acquire() as conn:
+ resolved = await resolve_product_mentions(conn, mentions)
+ await persist_product_mentions(
+ conn,
+ post_id,
+ source_body_digest,
+ input_digest,
+ session_id,
+ resolved,
+ )
+
+
async def _requeue_project_missing_case_jobs(
pool: asyncpg.Pool,
post_id: str,
@@ -440,6 +497,16 @@ async def process_post_content_job(
with use_llm_metadata(metadata):
vision_client = vision_factory()
if settings.orchestrator_base_url and settings.orchestrator_api_key:
+ await _persist_product_analysis_if_needed(
+ pool,
+ post_id,
+ source_body_digest,
+ row,
+ vision_client,
+ metadata["lineageweave_post_session_id"],
+ settings.orchestrator_base_url,
+ settings.orchestrator_api_key,
+ )
await _persist_operations_case_analysis_if_needed(
pool,
post_id,
diff --git a/backend/app/product_semantic_ingestion.py b/backend/app/product_semantic_ingestion.py
new file mode 100644
index 000000000..a2edbb110
--- /dev/null
+++ b/backend/app/product_semantic_ingestion.py
@@ -0,0 +1,83 @@
+"""Persist product mentions after fail-closed normalized catalog resolution."""
+
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+from lineageweave.product_semantics import (
+ ProductMention,
+ ResolvedProductMention,
+ normalize_product_alias,
+ resolve_product_mention,
+)
+
+
+class _Connection(Protocol):
+ def transaction(self) -> Any:
+ """Open an atomic database transaction."""
+ pass # pragma: no cover - structural protocol declaration
+
+ async def fetch(self, query: str, *args: object) -> list[Any]:
+ """Fetch parameterized rows."""
+ pass # pragma: no cover - structural protocol declaration
+
+ async def execute(self, query: str, *args: object) -> Any:
+ """Execute one parameterized statement."""
+ pass # pragma: no cover - structural protocol declaration
+
+
+async def resolve_product_mentions(
+ conn: _Connection, mentions: tuple[ProductMention, ...]
+) -> tuple[ResolvedProductMention, ...]:
+ """Resolve every mention by exact normalized alias, retaining ties."""
+ resolved: list[ResolvedProductMention] = []
+ for mention in mentions:
+ rows = await conn.fetch(
+ "select product_catalog_id from product_catalog_alias "
+ "where normalized_alias_text = $1 order by product_catalog_id",
+ normalize_product_alias(mention.extracted_product_name),
+ )
+ resolved.append(
+ resolve_product_mention(
+ mention, tuple(str(row["product_catalog_id"]) for row in rows)
+ )
+ )
+ return tuple(resolved)
+
+
+async def persist_product_mentions(
+ conn: _Connection,
+ post_id: str,
+ source_body_sha256: str,
+ analysis_input_sha256: str,
+ orchestrator_session_id: str,
+ mentions: tuple[ResolvedProductMention, ...],
+) -> None:
+ """Atomically replace one exact post's product analysis projection."""
+ async with conn.transaction():
+ await conn.execute("delete from post_product_analysis where post_id = $1", post_id)
+ await conn.execute(
+ "insert into post_product_analysis "
+ "(post_id, source_body_sha256, analysis_input_sha256, orchestrator_session_id) "
+ "values ($1, $2, $3, $4)",
+ post_id,
+ source_body_sha256,
+ analysis_input_sha256,
+ orchestrator_session_id,
+ )
+ for ordinal, resolved in enumerate(mentions):
+ mention = resolved.mention
+ await conn.execute(
+ "insert into post_product_mention "
+ "(post_id, mention_ordinal, product_catalog_id, extracted_product_name, "
+ "resolution_status_code, evidence_text, evidence_post_id, evidence_input_sha256) "
+ "values ($1, $2, $3, $4, $5, $6, $7, $8)",
+ post_id,
+ ordinal,
+ resolved.product_catalog_id,
+ mention.extracted_product_name,
+ resolved.resolution_status_code,
+ mention.evidence_text,
+ mention.evidence_post_id,
+ mention.evidence_input_sha256,
+ )
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 0d10021b8..710b838f9 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -194,6 +194,18 @@
/ "migrations"
/ "0203_global_ask_authorization_scope.sql"
)
+_PRODUCT_SEMANTIC_MIGRATIONS = tuple(
+ Path(__file__).resolve().parents[2] / "migrations" / name
+ for name in (
+ "0208_operations_case_analysis.sql",
+ "0209_operations_case_evidence_source.sql",
+ "0211_operations_case_missing_fact.sql",
+ "0213_operations_external_relation_target.sql",
+ "0215_operations_case_milestone.sql",
+ "0222_operations_case_analysis_input.sql",
+ "0228_product_semantic_catalog.sql",
+ )
+)
_LEFTOVER_MAP_AXIS_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
@@ -405,6 +417,8 @@ def seeded_db(demo_analyst_token):
cur.execute(_LEFTOVER_MAP_COVERAGE_MIGRATION.read_text())
cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text())
cur.execute(_GLOBAL_ASK_SCOPE_MIGRATION.read_text())
+ for migration_path in _PRODUCT_SEMANTIC_MIGRATIONS:
+ cur.execute(migration_path.read_text())
cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text())
cur.execute(_CHANNEL_EVIDENCE_MIGRATION.read_text())
@@ -1909,6 +1923,55 @@ def test_post_detail_uses_lookup_labels_not_raw_codes(client, demo_analyst_token
assert body["visibility_label"] == "Public"
+def test_post_detail_returns_authorized_product_evidence(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """The post response exposes only its persisted evidence-bound product link."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "insert into product_catalog "
+ "(canonical_product_name, product_level_code, product_catalog_code) "
+ "values (%s, %s, %s) returning product_catalog_id",
+ ("Synthetic Model Q", "product_model", "SYNTH-Q"),
+ )
+ catalog_id = cur.fetchone()[0]
+ cur.execute(
+ "insert into post_product_analysis "
+ "(post_id, source_body_sha256, analysis_input_sha256, orchestrator_session_id) "
+ "values (%s, %s, %s, %s)",
+ (seeded_db["public_post_id"], "a" * 64, "b" * 64, "session-a"),
+ )
+ cur.execute(
+ "insert into post_product_mention "
+ "(post_id, mention_ordinal, product_catalog_id, extracted_product_name, "
+ "resolution_status_code, evidence_text, evidence_post_id, evidence_input_sha256) "
+ "values (%s, 0, %s, %s, 'unique', %s, %s, %s)",
+ (
+ seeded_db["public_post_id"], catalog_id, "Synthetic Model Q",
+ "Synthetic evidence", seeded_db["public_post_id"], "c" * 64,
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ response = client.get(
+ f"/api/posts/{seeded_db['public_post_id']}",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200
+ assert response.json()["product_evidence"] == [{
+ "mention_ordinal": 0,
+ "extracted_product_name": "Synthetic Model Q",
+ "resolution_status_code": "unique",
+ "canonical_product_name": "Synthetic Model Q",
+ "product_level_code": "product_model",
+ "evidence_text": "Synthetic evidence",
+ "evidence_post_id": seeded_db["public_post_id"],
+ }]
+
+
def test_post_detail_exposes_explicit_and_semantic_project_evidence(
client, demo_analyst_token, seeded_db
) -> None:
diff --git a/backend/tests/test_product_semantic_ingestion.py b/backend/tests/test_product_semantic_ingestion.py
new file mode 100644
index 000000000..dcbee9d29
--- /dev/null
+++ b/backend/tests/test_product_semantic_ingestion.py
@@ -0,0 +1,68 @@
+"""Tests for normalized product semantic persistence."""
+
+from contextlib import asynccontextmanager
+import asyncio
+
+from backend.app.product_semantic_ingestion import (
+ persist_product_mentions,
+ resolve_product_mentions,
+)
+from lineageweave.product_semantics import ProductMention, ResolvedProductMention
+
+
+class _Connection:
+ def __init__(self, rows: list[dict[str, str]] | None = None) -> None:
+ self.rows = rows or []
+ self.calls: list[tuple[str, tuple[object, ...]]] = []
+
+ @asynccontextmanager
+ async def transaction(self):
+ yield
+
+ async def fetch(self, query: str, *args: object) -> list[dict[str, str]]:
+ self.calls.append((query, args))
+ return self.rows
+
+ async def execute(self, query: str, *args: object) -> None:
+ self.calls.append((query, args))
+
+
+def test_resolve_product_mentions_uses_parameterized_normalized_alias() -> None:
+ connection = _Connection([{"product_catalog_id": "catalog-a"}])
+ mention = ProductMention(" PRODUCT Q ", "PRODUCT", "post-a", "a" * 64)
+ resolved = asyncio.run(resolve_product_mentions(connection, (mention,)))
+ assert resolved[0].product_catalog_id == "catalog-a"
+ assert connection.calls[0][1] == ("product q",)
+
+
+def test_resolve_product_mentions_preserves_catalog_tie() -> None:
+ connection = _Connection(
+ [{"product_catalog_id": "catalog-a"}, {"product_catalog_id": "catalog-b"}]
+ )
+ mention = ProductMention("Product Q", "Product Q", "post-a", "a" * 64)
+ resolved = asyncio.run(resolve_product_mentions(connection, (mention,)))
+ assert resolved[0].resolution_status_code == "tie"
+ assert resolved[0].product_catalog_id is None
+
+
+def test_persist_product_mentions_replaces_exact_projection() -> None:
+ connection = _Connection()
+ mention = ProductMention("Product Q", "Product Q", "post-a", "a" * 64)
+ resolved = ResolvedProductMention(mention, "missing", None)
+ asyncio.run(
+ persist_product_mentions(
+ connection, "post-a", "b" * 64, "c" * 64, "session-a", (resolved,)
+ )
+ )
+ assert len(connection.calls) == 3
+ assert connection.calls[0][1] == ("post-a",)
+ assert connection.calls[2][1] == (
+ "post-a",
+ 0,
+ None,
+ "Product Q",
+ "missing",
+ "Product Q",
+ "post-a",
+ "a" * 64,
+ )
diff --git a/docs/adr/0228-evidence-bound-product-semantic-catalog.md b/docs/adr/0228-evidence-bound-product-semantic-catalog.md
new file mode 100644
index 000000000..62a10755e
--- /dev/null
+++ b/docs/adr/0228-evidence-bound-product-semantic-catalog.md
@@ -0,0 +1,84 @@
+# ADR 0228: Evidence-bound product semantic catalog
+
+- Status: Accepted
+- Date: 2026-08-26
+- Governs: product extraction, identity resolution, typed product relations, and historical backfill
+
+## Context
+
+Product references currently remain inside source text or unrelated operational
+facts. Treating a word or tag as a product would conflate a text match with an
+identified business entity, while forcing a best match would hide homonyms.
+Imported weak or blank category/customer values remain raw source provenance,
+not final semantic categories or resolved identities.
+ADR 0184 also requires typed ontology navigation to remain distinct from Event
+Lineage. ADRs 0036, 0052, and 0206 require authorized source evidence and exact
+input provenance for semantic and operational assertions.
+
+## Decision
+
+`product_catalog` is the shared product identity across `product_group`,
+`product_model`, `variant`, and `trade_item` levels. A parent foreign key
+retains that hierarchy. Scoped GTIN and MPN identifiers live in
+`product_catalog_identifier`; an identifier without issuer scope is not an
+identity. `product_catalog_alias` is its normalized lookup vocabulary. Multiple catalog
+identities may intentionally share an alias. A contextual-orchestrator
+structured extraction supplies only product mentions and verbatim source
+spans. LineageWeave validates the span against the authorized source, records
+its post and SHA-256 digest, and resolves the normalized alias with four
+outcomes:
+
+- exactly one catalog identity: `unique`, with its foreign key;
+- no catalog identity: `missing`, without a foreign key;
+- more than one identity: `tie`, without a foreign key.
+- unavailable catalog lookup: `unavailable`, without a foreign key.
+
+Neither `missing` nor `tie` creates a catalog row. Keywords, tags, fuzzy
+thresholds, provider calls, and locally guessed identities are prohibited.
+Relations to operational facts and project mentions use foreign keys to the
+existing normalized stores. These typed relations are an ontology navigation
+projection, not Event Lineage.
+
+```mermaid
+flowchart LR
+ S[source_post] -->|authorized span and digest| M[post_product_mention]
+ A[product_catalog_alias] -->|unique only| M
+ M --> P[product_catalog]
+ M --> F[operations_case_fact]
+ M --> J[post_project_mention]
+```
+
+Historical processing reuses the durable post-content queue boundary, with a
+bounded operator request and digest idempotency. HTTP requests never perform
+the extraction inline. Publication applies the existing authorization filter
+before returning the mention, relation, or evidence link.
+
+## Consequences
+
+- A product connection is auditable back to an exact authorized source span.
+- Catalog ambiguity remains visible and cannot silently become identity.
+- Operational and project relations reuse their existing evidence-bearing
+ normalized objects instead of duplicating unstructured values.
+- Catalog stewardship is required before missing or tied mentions can become
+ linked products.
+- High-volume deployments can partition mention and relation tables by a
+ future tenant/time key without changing their logical contract; indexes put
+ lookup keys before post identifiers to avoid one hot post partition.
+
+## Alternatives rejected
+
+- Keyword or tag classification: lexical occurrence does not establish product
+ identity or a typed business relation.
+- Model-generated catalog creation: generated identities cannot satisfy the
+ unique/miss/tie evidence boundary.
+- One polymorphic relation target column: it weakens referential integrity and
+ violates the normalized ownership of projects and operational facts.
+
+## References
+
+Bhattacharya, I., & Getoor, L. (2007). Collective entity resolution in
+relational data. *ACM Transactions on Knowledge Discovery from Data, 1*(1),
+Article 5. https://doi.org/10.1145/1217299.1217304
+
+Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*.
+World Wide Web Consortium. https://www.w3.org/TR/prov-dm/
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 2245a7cd8..6a85c77e5 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -27,6 +27,7 @@ decision from them.
| Ask answer citation and evidence-timeline interaction | [0225](0225-ask-answer-evidence-timeline.md) |
| macOS-native Rust/MLX mathematical compute boundary | [0226](0226-macos-native-mlx-mathematical-compute-boundary.md), [0208](0208-externalize-local-mathematical-compute.md) |
| Observed PostgreSQL WAL/checkpoint tuning plan | [0227](0227-observed-postgresql-runtime-tuning.md) |
+| Product semantic catalog and typed evidence relations | [0228](0228-evidence-bound-product-semantic-catalog.md) |
[0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/).
diff --git a/docs/ontology/lineageweave-kg-shapes.ttl b/docs/ontology/lineageweave-kg-shapes.ttl
index 187ebb0f1..13cf595e1 100644
--- a/docs/ontology/lineageweave-kg-shapes.ttl
+++ b/docs/ontology/lineageweave-kg-shapes.ttl
@@ -186,6 +186,30 @@
sh:class :Post ;
] .
+:ProductMentionShape a sh:NodeShape ;
+ rdfs:label "Evidence-bound product mention shape" ;
+ sh:targetClass :ProductMention ;
+ sh:property [
+ sh:path :extractedProductName ;
+ sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:minLength 1 ;
+ ] ;
+ sh:property [
+ sh:path :productResolutionStatus ;
+ sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in ("unique" "missing" "tie" "unavailable") ;
+ ] ;
+ sh:property [
+ sh:path :evidenceInputDigest ;
+ sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ;
+ sh:minCount 1 ; sh:maxCount 1 ;
+ sh:class :Post ;
+ ] .
+
:OurSidePersonShape a sh:NodeShape ;
rdfs:label "Our-side person shape" ;
sh:comment "Closed-world complement of :OurSidePerson owl:disjointWith :CounterpartyPerson: an instance of one can never be typed as the other." ;
diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl
index 90ef1c4cb..f59b50650 100644
--- a/docs/ontology/lineageweave-kg.ttl
+++ b/docs/ontology/lineageweave-kg.ttl
@@ -467,6 +467,26 @@
rdfs:subClassOf rdf:Statement, prov:Entity ;
rdfs:label "Operations case fact"@en .
+:Product a owl:Class ;
+ rdfs:label "Product"@en ;
+ rdfs:comment "A governed product catalog identity at group, model, variant, or trade-item level."@en .
+
+:ProductMention a owl:Class ;
+ rdfs:label "Product mention"@en ;
+ rdfs:comment "A source-span-bound product mention with a fail-closed catalog resolution outcome."@en .
+
+:mentionsProduct a owl:ObjectProperty ;
+ rdfs:domain :ProductMention ; rdfs:range :Product .
+
+:extractedProductName a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:productResolutionStatus a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:evidenceInputDigest a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
:Order a owl:Class ; rdfs:label "Order"@en .
:SalesContext a owl:Class ; rdfs:label "Sales context"@en .
:BusinessManagementContext a owl:Class ; rdfs:label "Business-management context"@en .
diff --git a/docs/product-requirements.md b/docs/product-requirements.md
index fcb9f741e..eb39a775f 100644
--- a/docs/product-requirements.md
+++ b/docs/product-requirements.md
@@ -185,12 +185,25 @@ A release claim requires one exact protected-main head that proves:
7. synchronized PRD, ADR, architecture, API, changelog, and product-gap
baseline.
+### 6.1 Product identity and evidence relationships
+
+The product must extract product mentions through contextual-orchestrator from
+authorized semantic source units, validate verbatim evidence, and resolve only
+against the normalized product catalog. Product group, model, variant, and
+trade-item identities preserve their hierarchy and scoped GTIN/MPN keys.
+Unique, tied, missing, and unavailable outcomes remain distinct. Product links
+to posts, projects, orders, sales pools, specification changes, claims, and
+external information reuse normalized evidence-bearing records and never
+derive identity from keywords, tags, weak source sentinels, or arbitrary
+similarity thresholds. Historical processing is bounded, asynchronous,
+digest-idempotent, and authorization-filtered when read.
+
## 7. Traceability
- Product/data boundary: ADR 0001, ADR 0089.
- Asynchronous delivery and database-pool isolation: ADR 0204.
- Knowledge Graph, ontology, and provenance: ADR 0004, ADR 0011, ADR 0065,
- ADR 0184, ADR 0207.
+ ADR 0184, ADR 0207, ADR 0228.
- Semantic units and retrieval: ADR 0047, ADR 0062, ADR 0102.
- LLM/model boundary: ADR 0070, ADR 0072, ADR 0076, ADR 0079.
- Measurement: ADR 0003, ADR 0145, ADR 0200, ADR 0205.
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 2042cb85c..07add918c 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -455,6 +455,7 @@ this file per §3.5 of the prior snapshot).
| Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing |
| Semantic source rendering | ADR 0223 and migration 0221 give new paragraph, list, table, MathML formula, and caller-parsed conversation-turn units explicit persisted kinds without rewriting historical rows; image regions remain ordered normalized children under ADR 0091. This branch is candidate evidence, not protected-main delivery | Land the exact-head candidate, then prove an authorized semantic-only query retrieves each persisted unit kind and gather authenticated browser evidence that nesting, continuation alignment, formula units, and image regions retain source order |
| Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence |
+| Product semantic identity | ADR 0228 and migration 0228 define normalized product group/model/variant/trade-item identities, scoped GTIN/MPN keys, exact-span provenance, fail-closed unique/tie/missing/unavailable resolution, and foreign-key relations to existing project and operations facts. The worker candidate reuses the durable post-content queue and skips an unchanged authorized input digest. No authorized-corpus product counts or rendered acceptance evidence are recorded | Land the stack, add authorization-filtered Post/Dashboard relationship reads and SHACL projection, then verify aggregate-only backfill outcomes plus desktop/mobile Storybook screenshots without exposing identifying runtime rows |
| Knowledge Graph readability | The black evidence-node root cause is an undefined-token fallback; the design-token repair and long-label/evidence-table coverage remain only on closed, unmerged #490, not protected `main` | Recreate the token repair on a current base and deliver it through protected `main`, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface |
| Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding |
| Calendar / Naruon | #355 delivered the projection contract; v2.17.0 wires operator consumption without forwarding the end-user token. Naruon producer, provider/consumer fixtures, and protected merge remain open (#336) | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired |
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index 36f6fbb44..0ab070b07 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -17,6 +17,7 @@ operator-facing control you can click before changing product CSS.
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
| `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` |
| `Evidence/OntologyExplorer` | Distinguish Post, Person, Organization, and Team by shape and text, use the token-backed surface as a secondary cue, then open the exact-value table or cited evidence. Compare desktop, narrow, drawer, empty, truncated, denied, stale, and rejected states. | `--ontology-node-*-fill`, `OntologyExplorer` |
+| `Post/ProductEvidenceList` | Open the cited product span. If the identity is unresolved, review the product catalog before using the relationship. Compare catalog-linked and catalog-review-required states. | `--surface`, `--border`, `ProductEvidenceList` |
Repeated web objects must use `frontend/src/styles/tokens.css` and a module
under `frontend/src/components/`. Do not add a second Node package manager;
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 856809e6d..fbdd7cecf 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -121,6 +121,7 @@ import {
useLocale,
} from "./i18n";
import "./App.css";
+import { ProductEvidenceList } from "./components/ProductEvidenceList";
function orchestratorUnavailableMessage(err: unknown, action: string): string {
if (err instanceof BackendError && err.status === 503) {
@@ -2148,6 +2149,7 @@ function PostDetailPopup({
)}
+ {post.product_evidence?.length ? : null}
{(post.source_stage_code ||
post.source_detail_state_code ||
post.source_draft_code ||
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index e819a063a..b1119304c 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -213,6 +213,17 @@ export interface PostKnownAt {
export interface PostDetail extends PostSummary {
post_body: string;
known_at?: PostKnownAt;
+ product_evidence?: ProductEvidence[];
+}
+
+export interface ProductEvidence {
+ mention_ordinal: number;
+ extracted_product_name: string;
+ resolution_status_code: "unique" | "missing" | "tie" | "unavailable";
+ canonical_product_name: string | null;
+ product_level_code: "product_group" | "product_model" | "variant" | "trade_item" | null;
+ evidence_text: string;
+ evidence_post_id: string;
}
export interface PostImageContent {
diff --git a/frontend/src/components/ProductEvidenceList.stories.tsx b/frontend/src/components/ProductEvidenceList.stories.tsx
new file mode 100644
index 000000000..cc86dcf1a
--- /dev/null
+++ b/frontend/src/components/ProductEvidenceList.stories.tsx
@@ -0,0 +1,38 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { ProductEvidenceList } from "./ProductEvidenceList";
+
+const meta = {
+ title: "Post/ProductEvidenceList",
+ component: ProductEvidenceList,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const CatalogLinked: Story = {
+ args: {
+ products: [{
+ mention_ordinal: 0,
+ extracted_product_name: "Synthetic Model Q",
+ canonical_product_name: "Synthetic Model Q",
+ product_level_code: "product_model",
+ resolution_status_code: "unique",
+ evidence_text: "Synthetic Model Q was selected for the trial.",
+ evidence_post_id: "synthetic-post",
+ }],
+ },
+};
+
+export const CatalogReviewRequired: Story = {
+ args: {
+ products: [{
+ mention_ordinal: 0,
+ extracted_product_name: "Synthetic Model Q",
+ canonical_product_name: null,
+ product_level_code: null,
+ resolution_status_code: "tie",
+ evidence_text: "Synthetic Model Q was selected for the trial.",
+ evidence_post_id: "synthetic-post",
+ }],
+ },
+};
diff --git a/frontend/src/components/ProductEvidenceList.test.tsx b/frontend/src/components/ProductEvidenceList.test.tsx
new file mode 100644
index 000000000..3b946ca55
--- /dev/null
+++ b/frontend/src/components/ProductEvidenceList.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { ProductEvidenceList } from "./ProductEvidenceList";
+
+describe("ProductEvidenceList", () => {
+ it("shows the next catalog action only for an unresolved identity", () => {
+ render( );
+ expect(screen.getByRole("status")).toHaveTextContent("product catalog");
+ });
+});
diff --git a/frontend/src/components/ProductEvidenceList.tsx b/frontend/src/components/ProductEvidenceList.tsx
new file mode 100644
index 000000000..03352c2fb
--- /dev/null
+++ b/frontend/src/components/ProductEvidenceList.tsx
@@ -0,0 +1,23 @@
+import type { ProductEvidence } from "../api";
+import { t } from "../i18n";
+
+export function ProductEvidenceList({ products }: { products: ProductEvidence[] }) {
+ return (
+
+ {t("Product evidence")}
+
+
+ );
+}
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index f2a363453..13b14c711 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -103,6 +103,8 @@ const TRANSLATIONS: Partial>> = {
Board: "게시판",
"Post body preview": "본문 미리보기",
"Post body": "본문",
+ "Product evidence": "제품 근거",
+ "Review the product catalog before using this relationship.": "이 관계를 사용하기 전에 제품 카탈로그를 확인하세요.",
"No post body.": "본문 없음",
"Authorized posts in this board.": "이 게시판에서 권한이 있는 글입니다.",
"Publication state": "공개 상태",
@@ -624,6 +626,8 @@ const TRANSLATIONS: Partial>> = {
Board: "看板",
"Post body preview": "正文预览",
"Post body": "正文",
+ "Product evidence": "产品依据",
+ "Review the product catalog before using this relationship.": "使用此关系前,请检查产品目录。",
"No post body.": "无正文",
"Authorized posts in this board.": "此看板中的授权文章。",
"Publication state": "公开状态",
@@ -1161,6 +1165,8 @@ const TRANSLATIONS: Partial>> = {
Board: "掲示板",
"Post body preview": "本文プレビュー",
"Post body": "本文",
+ "Product evidence": "製品の根拠",
+ "Review the product catalog before using this relationship.": "この関係を使用する前に製品カタログを確認してください。",
"No post body.": "本文なし",
"Authorized posts in this board.": "この掲示板で権限のある投稿です。",
"Publication state": "公開状態",
@@ -1677,6 +1683,8 @@ const TRANSLATIONS: Partial>> = {
Board: "Bảng tin",
"Post body preview": "Xem trước nội dung",
"Post body": "Nội dung bài đăng",
+ "Product evidence": "Bằng chứng sản phẩm",
+ "Review the product catalog before using this relationship.": "Hãy kiểm tra danh mục sản phẩm trước khi sử dụng mối quan hệ này.",
"No post body.": "Không có nội dung",
"Authorized posts in this board.": "Các bài viết được cấp quyền trong bảng tin này.",
"Publication state": "Trạng thái công khai",
diff --git a/lineageweave/product_semantics.py b/lineageweave/product_semantics.py
new file mode 100644
index 000000000..dcedbe2d9
--- /dev/null
+++ b/lineageweave/product_semantics.py
@@ -0,0 +1,166 @@
+"""Evidence-bound product extraction and fail-closed catalog resolution."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import unicodedata
+from dataclasses import dataclass
+
+from .http_client import chat_completion_content, post_json
+
+
+@dataclass(frozen=True)
+class ProductEvidenceSource:
+ """One authorized source whose exact text may support a product mention."""
+
+ post_id: str
+ text: str
+
+ @property
+ def input_sha256(self) -> str:
+ """Return the digest binding derived evidence to this source text."""
+ return hashlib.sha256(self.text.encode("utf-8")).hexdigest()
+
+
+@dataclass(frozen=True)
+class ProductMention:
+ """One validated product span, not yet forced onto a catalog identity."""
+
+ extracted_product_name: str
+ evidence_text: str
+ evidence_post_id: str
+ evidence_input_sha256: str
+
+
+@dataclass(frozen=True)
+class ResolvedProductMention:
+ """A mention with a unique, missing, or tied catalog outcome."""
+
+ mention: ProductMention
+ resolution_status_code: str
+ product_catalog_id: str | None
+
+
+def normalize_product_alias(value: str) -> str:
+ """Normalize catalog lookup text without deriving identity from keywords."""
+ return " ".join(unicodedata.normalize("NFKC", value).casefold().split())
+
+
+def product_analysis_input_sha256(
+ sources: tuple[ProductEvidenceSource, ...],
+) -> str:
+ """Digest the exact ordered authorized source window used for extraction."""
+ encoded = json.dumps(
+ [(source.post_id, source.input_sha256) for source in sources],
+ separators=(",", ":"),
+ ensure_ascii=False,
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def parse_product_mentions(
+ content: str, sources: tuple[ProductEvidenceSource, ...]
+) -> tuple[ProductMention, ...] | None:
+ """Validate structured output against exact authorized source spans."""
+ source_by_id = {source.post_id: source for source in sources}
+ try:
+ payload = json.loads(content)
+ except json.JSONDecodeError:
+ return None
+ if not isinstance(payload, list):
+ return None
+ mentions: list[ProductMention] = []
+ seen: set[tuple[str, str, str]] = set()
+ for item in payload:
+ if not isinstance(item, dict):
+ return None
+ name = item.get("product_name")
+ evidence = item.get("evidence_text")
+ post_id = item.get("evidence_post_id")
+ source = source_by_id.get(post_id)
+ if (
+ not isinstance(name, str)
+ or not name.strip()
+ or not isinstance(evidence, str)
+ or not evidence.strip()
+ or source is None
+ or evidence not in source.text
+ ):
+ return None
+ key = (normalize_product_alias(name), evidence, post_id)
+ if key in seen:
+ return None
+ seen.add(key)
+ mentions.append(ProductMention(name.strip(), evidence, post_id, source.input_sha256))
+ return tuple(mentions)
+
+
+def resolve_product_mention(
+ mention: ProductMention, catalog_matches: tuple[str, ...] | None
+) -> ResolvedProductMention:
+ """Bind only one exact normalized catalog match; preserve misses and ties."""
+ if catalog_matches is None:
+ return ResolvedProductMention(mention, "unavailable", None)
+ distinct = tuple(dict.fromkeys(catalog_matches))
+ if len(distinct) == 1:
+ return ResolvedProductMention(mention, "unique", distinct[0])
+ return ResolvedProductMention(
+ mention, "missing" if not distinct else "tie", None
+ )
+
+
+_PROMPT = """Extract product entities from the authorized sources semantically.
+Do not classify by keywords or tags and do not invent a product. Return ONLY a
+JSON array. Each object has product_name, evidence_post_id, and evidence_text.
+evidence_text must be a verbatim span that identifies the product in that same
+source. Return [] when no source span supports a product entity.
+
+Authorized sources:
+{sources}
+"""
+
+
+class ContextualOrchestratorProductExtractionClient:
+ """Extract cited product mentions through the provider-neutral gateway."""
+
+ available = True
+
+ def __init__(self, base_url: str, api_key: str, *, timeout: float = 180.0) -> None:
+ self._base_url = base_url.rstrip("/")
+ self._api_key = api_key
+ self._timeout = timeout
+
+ def extract(
+ self, sources: tuple[ProductEvidenceSource, ...]
+ ) -> tuple[ProductMention, ...]:
+ """Return only fully validated, source-bound product mentions."""
+ response = post_json(
+ f"{self._base_url}/v1/chat/completions",
+ {
+ "model": "orchestrator/auto",
+ "messages": [
+ {
+ "role": "user",
+ "content": _PROMPT.format(
+ sources="\n\n".join(
+ f"post_id={source.post_id}\n{source.text}"
+ for source in sources
+ )
+ ),
+ }
+ ],
+ "mode": "auto",
+ "reasoning_effort": "auto",
+ },
+ timeout=self._timeout,
+ headers={
+ "authorization": f"Bearer {self._api_key}",
+ "x-request-timeout-ms": str(round(self._timeout * 1000)),
+ },
+ )
+ content = chat_completion_content(response)
+ parsed = parse_product_mentions(content, sources)
+ if parsed is None:
+ raise RuntimeError("contextual-orchestrator returned invalid product evidence")
+ return parsed
diff --git a/migrations/0228_product_semantic_catalog.sql b/migrations/0228_product_semantic_catalog.sql
new file mode 100644
index 000000000..0e9a83aa1
--- /dev/null
+++ b/migrations/0228_product_semantic_catalog.sql
@@ -0,0 +1,89 @@
+-- ADR 0228: evidence-bound product identity and operational relationships.
+create table if not exists product_catalog (
+ product_catalog_id uuid primary key default gen_random_uuid(),
+ canonical_product_name text not null check (btrim(canonical_product_name) <> ''),
+ product_level_code text not null
+ check (product_level_code in ('product_group', 'product_model', 'variant', 'trade_item')),
+ parent_product_catalog_id uuid references product_catalog(product_catalog_id),
+ product_catalog_code text,
+ created_at timestamptz not null default now(),
+ unique (product_catalog_code)
+);
+
+create table if not exists product_catalog_identifier (
+ product_catalog_id uuid not null references product_catalog(product_catalog_id),
+ identifier_scheme_code text not null check (identifier_scheme_code in ('gtin', 'mpn')),
+ identifier_value text not null check (btrim(identifier_value) <> ''),
+ issuer_scope_text text not null check (btrim(issuer_scope_text) <> ''),
+ primary key (identifier_scheme_code, identifier_value, issuer_scope_text),
+ unique (product_catalog_id, identifier_scheme_code, identifier_value, issuer_scope_text)
+);
+
+create table if not exists product_catalog_alias (
+ product_catalog_id uuid not null references product_catalog(product_catalog_id),
+ normalized_alias_text text not null check (btrim(normalized_alias_text) <> ''),
+ alias_text text not null check (btrim(alias_text) <> ''),
+ primary key (product_catalog_id, normalized_alias_text)
+);
+create index if not exists product_catalog_alias_lookup_idx
+ on product_catalog_alias (normalized_alias_text, product_catalog_id);
+
+create table if not exists post_product_analysis (
+ post_id uuid primary key references source_post(post_id) on delete cascade,
+ source_body_sha256 text not null check (source_body_sha256 ~ '^[0-9a-f]{64}$'),
+ analysis_input_sha256 text not null check (analysis_input_sha256 ~ '^[0-9a-f]{64}$'),
+ orchestrator_session_id text not null check (btrim(orchestrator_session_id) <> ''),
+ analyzed_at timestamptz not null default now()
+);
+
+create table if not exists post_product_mention (
+ post_id uuid not null references post_product_analysis(post_id) on delete cascade,
+ mention_ordinal integer not null check (mention_ordinal >= 0),
+ product_catalog_id uuid references product_catalog(product_catalog_id),
+ extracted_product_name text not null check (btrim(extracted_product_name) <> ''),
+ resolution_status_code text not null
+ check (resolution_status_code in ('unique', 'missing', 'tie', 'unavailable')),
+ evidence_text text not null check (btrim(evidence_text) <> ''),
+ evidence_post_id uuid not null references source_post(post_id),
+ evidence_input_sha256 text not null
+ check (evidence_input_sha256 ~ '^[0-9a-f]{64}$'),
+ primary key (post_id, mention_ordinal),
+ check ((resolution_status_code = 'unique') = (product_catalog_id is not null))
+);
+create index if not exists post_product_mention_catalog_idx
+ on post_product_mention (product_catalog_id, post_id)
+ where product_catalog_id is not null;
+
+create table if not exists product_operations_fact_relation (
+ post_id uuid not null,
+ mention_ordinal integer not null,
+ case_kind_code text not null,
+ fact_ordinal integer not null,
+ relation_type_code text not null
+ check (relation_type_code in ('concerns_product', 'changes_product', 'originates_from_product', 'senses_product')),
+ evidence_text text not null check (btrim(evidence_text) <> ''),
+ evidence_post_id uuid not null references source_post(post_id),
+ evidence_input_sha256 text not null
+ check (evidence_input_sha256 ~ '^[0-9a-f]{64}$'),
+ primary key (post_id, mention_ordinal, case_kind_code, fact_ordinal, relation_type_code),
+ foreign key (post_id, mention_ordinal)
+ references post_product_mention(post_id, mention_ordinal) on delete cascade,
+ foreign key (post_id, case_kind_code, fact_ordinal)
+ references operations_case_fact(post_id, case_kind_code, fact_ordinal) on delete cascade
+);
+
+create table if not exists product_project_relation (
+ post_id uuid not null,
+ mention_ordinal integer not null,
+ project_key text not null,
+ relation_type_code text not null check (relation_type_code = 'used_by_project'),
+ evidence_text text not null check (btrim(evidence_text) <> ''),
+ evidence_post_id uuid not null references source_post(post_id),
+ evidence_input_sha256 text not null
+ check (evidence_input_sha256 ~ '^[0-9a-f]{64}$'),
+ primary key (post_id, mention_ordinal, project_key),
+ foreign key (post_id, mention_ordinal)
+ references post_product_mention(post_id, mention_ordinal) on delete cascade,
+ foreign key (post_id, project_key)
+ references post_project_mention(post_id, project_key) on delete cascade
+);
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index 6b0fe4743..5f6af0814 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -20,6 +20,18 @@
)
from lineageweave.operations_case_analysis import OperationsEvidenceSource
+_PRODUCT_ANALYSIS = post_content_worker._persist_product_analysis_if_needed
+
+
+@pytest.fixture(autouse=True)
+def _isolate_product_analysis(monkeypatch):
+ """Keep legacy worker tests focused on their pre-product responsibility."""
+ monkeypatch.setattr(
+ post_content_worker,
+ "_persist_product_analysis_if_needed",
+ lambda *_args, **_kwargs: asyncio.sleep(0),
+ )
+
class _Transaction:
async def __aenter__(self):
@@ -398,6 +410,74 @@ async def evidence_sources(*_args, **_kwargs):
assert called == []
+def test_product_analysis_persists_one_exact_authorized_window(monkeypatch) -> None:
+ """Product extraction reuses authorized sources and persists catalog outcomes."""
+ connection = _Connection(values=[False])
+ events: list[object] = []
+
+ async def evidence_sources(*_args, **_kwargs):
+ return (OperationsEvidenceSource("post-1", "Synthetic", "Synthetic Product Q"),)
+
+ async def resolve(_conn, mentions):
+ events.append(mentions)
+ return (SimpleNamespace(
+ mention=mentions[0], resolution_status_code="missing", product_catalog_id=None
+ ),)
+
+ async def persist(*args):
+ events.append(args)
+
+ monkeypatch.setattr(post_content_worker, "_operations_evidence_sources", evidence_sources)
+ monkeypatch.setattr(
+ post_content_worker,
+ "ContextualOrchestratorProductExtractionClient",
+ lambda *_args: SimpleNamespace(
+ extract=lambda sources: (
+ post_content_worker.ProductEvidenceSource(sources[0].post_id, sources[0].text),
+ )
+ ),
+ )
+ monkeypatch.setattr(post_content_worker, "resolve_product_mentions", resolve)
+ monkeypatch.setattr(post_content_worker, "persist_product_mentions", persist)
+
+ asyncio.run(
+ _PRODUCT_ANALYSIS(
+ _Pool(connection),
+ "post-1",
+ "a" * 64,
+ {"corporate_entity_id": "corp", "process_unit_id": "pu"},
+ SimpleNamespace(available=True),
+ "session-a",
+ "gateway",
+ "key",
+ )
+ )
+ assert len(events) == 2
+ assert len(events[1][3]) == 64
+
+
+def test_product_analysis_skips_same_digest(monkeypatch) -> None:
+ """A durable retry does not repeat product extraction for the same input."""
+ connection = _Connection(values=[True])
+
+ async def evidence_sources(*_args, **_kwargs):
+ return (OperationsEvidenceSource("post-1", "Synthetic", "Synthetic Product Q"),)
+
+ monkeypatch.setattr(post_content_worker, "_operations_evidence_sources", evidence_sources)
+ monkeypatch.setattr(
+ post_content_worker,
+ "ContextualOrchestratorProductExtractionClient",
+ lambda *_args: (_ for _ in ()).throw(AssertionError("must not call provider")),
+ )
+ asyncio.run(
+ _PRODUCT_ANALYSIS(
+ _Pool(connection), "post-1", "a" * 64,
+ {"corporate_entity_id": "corp", "process_unit_id": "pu"},
+ SimpleNamespace(available=True), "session-a", "gateway", "key",
+ )
+ )
+
+
def test_changed_evidence_window_reanalyzes_unchanged_body(monkeypatch) -> None:
"""A newly available sibling invalidates reuse without changing focal text."""
connection = _Connection(values=[False])
diff --git a/tests/test_product_semantics.py b/tests/test_product_semantics.py
new file mode 100644
index 000000000..03f5cb1a2
--- /dev/null
+++ b/tests/test_product_semantics.py
@@ -0,0 +1,115 @@
+"""Tests for evidence-bound product semantic extraction."""
+
+from lineageweave.product_semantics import (
+ ContextualOrchestratorProductExtractionClient,
+ ProductEvidenceSource,
+ ProductMention,
+ normalize_product_alias,
+ parse_product_mentions,
+ product_analysis_input_sha256,
+ resolve_product_mention,
+)
+import pytest
+
+
+def test_parse_product_mentions_binds_exact_source_span() -> None:
+ source = ProductEvidenceSource("post-a", "Synthetic Model Q supports the test.")
+ parsed = parse_product_mentions(
+ '[{"product_name":"Synthetic Model Q","evidence_post_id":"post-a",'
+ '"evidence_text":"Synthetic Model Q"}]',
+ (source,),
+ )
+ assert parsed == (
+ ProductMention(
+ "Synthetic Model Q", "Synthetic Model Q", "post-a", source.input_sha256
+ ),
+ )
+ assert len(product_analysis_input_sha256((source,))) == 64
+
+
+def test_parse_product_mentions_rejects_uncited_and_duplicate_output() -> None:
+ source = ProductEvidenceSource("post-a", "Synthetic Model Q")
+ assert parse_product_mentions(
+ '[{"product_name":"Other","evidence_post_id":"post-a",'
+ '"evidence_text":"Other"}]',
+ (source,),
+ ) is None
+ item = (
+ '{"product_name":"Synthetic Model Q","evidence_post_id":"post-a",'
+ '"evidence_text":"Synthetic Model Q"}'
+ )
+ assert parse_product_mentions(f"[{item},{item}]", (source,)) is None
+
+
+def test_parse_product_mentions_rejects_invalid_shapes() -> None:
+ source = ProductEvidenceSource("post-a", "Synthetic Model Q")
+ assert parse_product_mentions("not-json", (source,)) is None
+ assert parse_product_mentions("{}", (source,)) is None
+ assert parse_product_mentions("[1]", (source,)) is None
+ assert parse_product_mentions(
+ '[{"product_name":"","evidence_post_id":"post-a","evidence_text":"x"}]',
+ (source,),
+ ) is None
+
+
+def test_catalog_resolution_is_unique_missing_or_tie() -> None:
+ mention = ProductMention(" Product Q ", "Product Q", "post-a", "a" * 64)
+ assert normalize_product_alias(" PRODUCT Q ") == "product q"
+ unique = resolve_product_mention(mention, ("catalog-a", "catalog-a"))
+ missing = resolve_product_mention(mention, ())
+ tie = resolve_product_mention(mention, ("catalog-a", "catalog-b"))
+ unavailable = resolve_product_mention(mention, None)
+ assert (unique.resolution_status_code, unique.product_catalog_id) == (
+ "unique",
+ "catalog-a",
+ )
+ assert (missing.resolution_status_code, missing.product_catalog_id) == (
+ "missing",
+ None,
+ )
+ assert (tie.resolution_status_code, tie.product_catalog_id) == ("tie", None)
+ assert (unavailable.resolution_status_code, unavailable.product_catalog_id) == (
+ "unavailable",
+ None,
+ )
+
+
+def test_orchestrator_product_client_uses_auto_and_timeout(monkeypatch) -> None:
+ captured: dict[str, object] = {}
+
+ def fake_post(url, payload, *, headers, timeout):
+ captured.update(url=url, payload=payload, headers=headers, timeout=timeout)
+ return {
+ "choices": [
+ {
+ "message": {
+ "content": '[{"product_name":"Synthetic Model Q",'
+ '"evidence_post_id":"post-a","evidence_text":"Synthetic Model Q"}]'
+ }
+ }
+ ]
+ }
+
+ monkeypatch.setattr("lineageweave.product_semantics.post_json", fake_post)
+ source = ProductEvidenceSource("post-a", "Synthetic Model Q")
+ result = ContextualOrchestratorProductExtractionClient(
+ "https://orchestrator.invalid/", "secret", timeout=12.5
+ ).extract((source,))
+ assert result[0].evidence_post_id == "post-a"
+ assert captured["url"] == "https://orchestrator.invalid/v1/chat/completions"
+ assert captured["payload"]["model"] == "orchestrator/auto"
+ assert captured["headers"] == {
+ "authorization": "Bearer secret",
+ "x-request-timeout-ms": "12500",
+ }
+
+
+def test_orchestrator_product_client_rejects_invalid_evidence(monkeypatch) -> None:
+ monkeypatch.setattr(
+ "lineageweave.product_semantics.post_json",
+ lambda *args, **kwargs: {"choices": [{"message": {"content": "{}"}}]},
+ )
+ with pytest.raises(RuntimeError, match="invalid product evidence"):
+ ContextualOrchestratorProductExtractionClient("https://x", "secret").extract(
+ (ProductEvidenceSource("post-a", "Synthetic Model Q"),)
+ )
From 4c39895c4364fdb359ed765afec73f833f379ac7 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 14:30:54 +0900
Subject: [PATCH 128/393] perf(worker): reuse authorized evidence window
---
backend/app/post_content_worker.py | 22 ++++++++++++++++------
tests/test_post_content_worker.py | 8 ++++++++
2 files changed, 24 insertions(+), 6 deletions(-)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 707fa3a94..ed2624cb0 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -130,6 +130,7 @@ async def _persist_operations_case_analysis_if_needed(
session_id: str,
orchestrator_base_url: str,
orchestrator_api_key: str,
+ evidence_sources: tuple[OperationsEvidenceSource, ...] | None = None,
) -> None:
"""Persist cases once per exact focal body and authorized evidence window."""
context = " | ".join(
@@ -143,9 +144,10 @@ async def _persist_operations_case_analysis_if_needed(
)
if row.get(name) is not None and str(row[name]).strip()
)
- evidence_sources = await _operations_evidence_sources(
- pool, post_id, row, vision_client
- )
+ if evidence_sources is None:
+ evidence_sources = await _operations_evidence_sources(
+ pool, post_id, row, vision_client
+ )
analysis_input_digest = operations_analysis_input_sha256(
evidence_sources, context
)
@@ -187,11 +189,14 @@ async def _persist_product_analysis_if_needed(
session_id: str,
orchestrator_base_url: str,
orchestrator_api_key: str,
+ evidence_sources: tuple[OperationsEvidenceSource, ...] | None = None,
) -> None:
"""Extract and persist products once per exact authorized source window."""
- operation_sources = await _operations_evidence_sources(
- pool, post_id, row, vision_client
- )
+ operation_sources = evidence_sources
+ if operation_sources is None:
+ operation_sources = await _operations_evidence_sources(
+ pool, post_id, row, vision_client
+ )
sources = tuple(
ProductEvidenceSource(source.post_id, source.text)
for source in operation_sources
@@ -497,6 +502,9 @@ async def process_post_content_job(
with use_llm_metadata(metadata):
vision_client = vision_factory()
if settings.orchestrator_base_url and settings.orchestrator_api_key:
+ evidence_sources = await _operations_evidence_sources(
+ pool, post_id, row, vision_client
+ )
await _persist_product_analysis_if_needed(
pool,
post_id,
@@ -506,6 +514,7 @@ async def process_post_content_job(
metadata["lineageweave_post_session_id"],
settings.orchestrator_base_url,
settings.orchestrator_api_key,
+ evidence_sources,
)
await _persist_operations_case_analysis_if_needed(
pool,
@@ -517,6 +526,7 @@ async def process_post_content_job(
metadata["lineageweave_post_session_id"],
settings.orchestrator_base_url,
settings.orchestrator_api_key,
+ evidence_sources,
)
normalized = await asyncio.to_thread(
normalize_post_body, raw_body, vision_client
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index 5f6af0814..c85086baf 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -550,6 +550,14 @@ async def finish(_pool, _post_id, status, **_kwargs):
"_persist_operations_case_analysis_if_needed",
lambda *_args, **_kwargs: asyncio.sleep(0),
)
+ monkeypatch.setattr(
+ post_content_worker,
+ "_operations_evidence_sources",
+ lambda *_args, **_kwargs: asyncio.sleep(
+ 0,
+ result=(OperationsEvidenceSource("post-1", "Synthetic", "Evidence"),),
+ ),
+ )
monkeypatch.setattr(post_content_worker, "normalize_post_body", lambda *_args: object())
monkeypatch.setattr(
post_content_worker,
From d8c3251655768db6a5b8bd7359848a1d3cc77afc Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 14:33:18 +0900
Subject: [PATCH 129/393] fix(products): enforce evidence-post visibility
---
backend/app/main.py | 10 ++++++++--
backend/tests/test_api.py | 14 ++++++++++++++
...0228-evidence-bound-product-semantic-catalog.md | 4 +++-
3 files changed, 25 insertions(+), 3 deletions(-)
diff --git a/backend/app/main.py b/backend/app/main.py
index ec0daadde..981ffce85 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1594,11 +1594,16 @@ async def read_post(
"select mention.mention_ordinal, mention.extracted_product_name, "
"mention.resolution_status_code, catalog.canonical_product_name, "
"catalog.product_level_code, mention.evidence_text, "
- "mention.evidence_post_id "
+ "mention.evidence_post_id, evidence_post.visibility_code, "
+ "evidence_post.corporate_entity_id, evidence_post.process_unit_id "
"from post_product_mention mention "
"left join product_catalog catalog "
"on catalog.product_catalog_id = mention.product_catalog_id "
- "where mention.post_id = $1 order by mention.mention_ordinal",
+ "join source_post evidence_post "
+ "on evidence_post.post_id = mention.evidence_post_id "
+ "where mention.post_id = $1 and "
+ f"{SOURCE_POST_ELIGIBILITY_SQL.format(alias='evidence_post')} "
+ "order by mention.mention_ordinal",
post_id,
)
known_at = None
@@ -1619,6 +1624,7 @@ async def read_post(
"evidence_post_id": item["evidence_post_id"],
}
for item in product_rows
+ if _can_see_post(account, item)
],
}
if known_at is not None:
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 710b838f9..2d56c1015 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -1953,6 +1953,20 @@ def test_post_detail_returns_authorized_product_evidence(
"Synthetic evidence", seeded_db["public_post_id"], "c" * 64,
),
)
+ cur.execute(
+ "insert into post_product_mention "
+ "(post_id, mention_ordinal, extracted_product_name, "
+ "resolution_status_code, evidence_text, evidence_post_id, "
+ "evidence_input_sha256) "
+ "values (%s, 1, %s, 'missing', %s, %s, %s)",
+ (
+ seeded_db["public_post_id"],
+ "Hidden Synthetic Model",
+ "Hidden synthetic evidence",
+ seeded_db["other_private_post_id"],
+ "d" * 64,
+ ),
+ )
conn.commit()
finally:
conn.close()
diff --git a/docs/adr/0228-evidence-bound-product-semantic-catalog.md b/docs/adr/0228-evidence-bound-product-semantic-catalog.md
index 62a10755e..5393fbba5 100644
--- a/docs/adr/0228-evidence-bound-product-semantic-catalog.md
+++ b/docs/adr/0228-evidence-bound-product-semantic-catalog.md
@@ -51,7 +51,9 @@ flowchart LR
Historical processing reuses the durable post-content queue boundary, with a
bounded operator request and digest idempotency. HTTP requests never perform
the extraction inline. Publication applies the existing authorization filter
-before returning the mention, relation, or evidence link.
+and source eligibility predicate to both the requested post and every evidence
+post before returning the mention, relation, or evidence link. A visible post
+cannot reveal a product span cited only by evidence the reader cannot access.
## Consequences
From b56921f17ff2068ad8f3954a6424910db7770072 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 14:34:44 +0900
Subject: [PATCH 130/393] fix(embeddings): surface sanitized provider failures
---
docker/contextual-orchestrator/Dockerfile | 2 +-
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
lineageweave/embedding_client.py | 9 ++++++++-
3 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 090e1852a..e41ec2bba 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/4fc7e417e636b246fbbbb2b215af2a7d6bd3ea65.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/2b11707e828c8d3f2e6cdf4b532172bf1797f786.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index af3fd25f3..5df621c4d 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `4fc7e417e636b246fbbbb2b215af2a7d6bd3ea65`. The pin remains explicit
+commit `2b11707e828c8d3f2e6cdf4b532172bf1797f786`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index 632c33e1d..6170b2090 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -125,7 +125,14 @@ def embed_many(
if vectors is not None:
return vectors
if response.get("status") in {"failed", "cancelled", "rejected"}:
- raise RuntimeError("embedding batch did not complete")
+ failure = response.get("failure")
+ failure_code = (
+ failure.get("provider_code") or failure.get("error_type")
+ if isinstance(failure, dict)
+ else None
+ )
+ suffix = f": {failure_code}" if failure_code else ""
+ raise RuntimeError(f"embedding batch did not complete{suffix}")
if time.monotonic() >= deadline:
raise TimeoutError("embedding batch timed out")
poll_after_ms = response.get("poll_after_ms")
From 19c9c9e4ae9afb765ec7831b086711f26531c9c3 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 14:36:23 +0900
Subject: [PATCH 131/393] test: remove redundant settings lambdas
---
tests/test_post_content_backfill_endpoint.py | 25 ++++++++-----------
...test_queue_post_content_backfill_script.py | 14 ++++++-----
2 files changed, 19 insertions(+), 20 deletions(-)
diff --git a/tests/test_post_content_backfill_endpoint.py b/tests/test_post_content_backfill_endpoint.py
index 3e81bf8eb..b90f552d7 100644
--- a/tests/test_post_content_backfill_endpoint.py
+++ b/tests/test_post_content_backfill_endpoint.py
@@ -61,15 +61,15 @@ async def enqueue(pool: object, valkey: object, **kwargs: object) -> dict[str, i
}
monkeypatch.setattr(main, "enqueue_post_content_backfill", enqueue)
- monkeypatch.setattr(
- main,
- "load_settings",
- lambda: type(
- "Settings",
- (),
- {"orchestrator_base_url": "https://orchestrator.invalid", "orchestrator_api_key": "configured"},
- )(),
+ settings_type = type(
+ "Settings",
+ (),
+ {
+ "orchestrator_base_url": "https://orchestrator.invalid",
+ "orchestrator_api_key": "configured",
+ },
)
+ monkeypatch.setattr(main, "load_settings", settings_type)
pool = object()
valkey = object()
result = asyncio.run(
@@ -106,13 +106,10 @@ async def enqueue(_pool: object, _valkey: object, **kwargs: object) -> dict[str,
}
monkeypatch.setattr(main, "enqueue_post_content_backfill", enqueue)
- monkeypatch.setattr(
- main,
- "load_settings",
- lambda: type(
- "Settings", (), {"orchestrator_base_url": "", "orchestrator_api_key": ""}
- )(),
+ settings_type = type(
+ "Settings", (), {"orchestrator_base_url": "", "orchestrator_api_key": ""}
)
+ monkeypatch.setattr(main, "load_settings", settings_type)
asyncio.run(
main.queue_post_content_backfill(
main.PostContentBackfillRequest(),
diff --git a/tests/test_queue_post_content_backfill_script.py b/tests/test_queue_post_content_backfill_script.py
index c76589644..389fcab6d 100644
--- a/tests/test_queue_post_content_backfill_script.py
+++ b/tests/test_queue_post_content_backfill_script.py
@@ -71,13 +71,15 @@ async def enqueue(_pool: object, _client: object, **kwargs: object) -> dict[str,
monkeypatch.setattr(script.asyncpg, "create_pool", create_pool)
monkeypatch.setattr(script.redis, "from_url", lambda *_args, **_kwargs: client)
monkeypatch.setattr(script, "enqueue_post_content_backfill", enqueue)
- monkeypatch.setattr(
- script,
- "load_settings",
- lambda: type(
- "Settings", (), {"orchestrator_base_url": "https://example.invalid", "orchestrator_api_key": "set"}
- )(),
+ settings_type = type(
+ "Settings",
+ (),
+ {
+ "orchestrator_base_url": "https://example.invalid",
+ "orchestrator_api_key": "set",
+ },
)
+ monkeypatch.setattr(script, "load_settings", settings_type)
result = asyncio.run(
script.queue_post_content_backfill("postgresql://invalid", "redis://invalid", limit=12)
)
From d92d0946f3c0511bb0572d396c21139982bbf690 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 14:41:14 +0900
Subject: [PATCH 132/393] build(orchestrator): pin OpenAI embedding limits
---
docker/contextual-orchestrator/Dockerfile | 2 +-
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index e41ec2bba..38bf9c871 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -5,7 +5,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/2b11707e828c8d3f2e6cdf4b532172bf1797f786.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/d078ce6bc42b7ec37308bfded6adbe1d700c069c.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 5df621c4d..ef34e8382 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `2b11707e828c8d3f2e6cdf4b532172bf1797f786`. The pin remains explicit
+commit `d078ce6bc42b7ec37308bfded6adbe1d700c069c`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
From 12400ebc5bec816eab5e686f9376b1dab2380af1 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 14:42:00 +0900
Subject: [PATCH 133/393] build(orchestrator): install exact embedding
tokenizer
---
docker/contextual-orchestrator/Dockerfile | 1 +
1 file changed, 1 insertion(+)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 38bf9c871..8bfa54618 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -16,6 +16,7 @@ RUN mkdir /tmp/contextual-orchestrator \
'opentelemetry-api>=1.30.0' \
'opentelemetry-sdk>=1.30.0' \
'opentelemetry-exporter-otlp-proto-http>=1.30.0' \
+ 'tiktoken>=0.11.0' \
&& useradd --uid 10001 --no-create-home orchestrator
COPY agents.json /app/agents.json
From a1c60f65cf6d3e4cb4ae8afc606bb2317cbb6a48 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 14:49:07 +0900
Subject: [PATCH 134/393] fix(embeddings): surface oversized backfill units
---
lineageweave/embedding_backfill.py | 5 ++++-
tests/test_embedding_backfill.py | 25 ++++++++++++++++++++++++-
2 files changed, 28 insertions(+), 2 deletions(-)
diff --git a/lineageweave/embedding_backfill.py b/lineageweave/embedding_backfill.py
index bf9993377..e128333e9 100644
--- a/lineageweave/embedding_backfill.py
+++ b/lineageweave/embedding_backfill.py
@@ -16,6 +16,9 @@
post.source_author_code, post.source_company_code,
post.source_customer_code, post.source_project_code,
post.source_sales_pool_code, entity.corporate_entity_code,
+ row_number() over (
+ order by post.created_at, post.post_id, unit.unit_index
+ ) as candidate_ordinal,
sum(octet_length(unit.unit_text) + 1) over (
order by post.created_at, post.post_id, unit.unit_index
) as cumulative_text_bytes
@@ -29,7 +32,7 @@
)
)
select * from candidates
- where cumulative_text_bytes <= $1
+ where candidate_ordinal = 1 or cumulative_text_bytes <= $1
order by cumulative_text_bytes
"""
diff --git a/tests/test_embedding_backfill.py b/tests/test_embedding_backfill.py
index 3e84b7668..4adb7134d 100644
--- a/tests/test_embedding_backfill.py
+++ b/tests/test_embedding_backfill.py
@@ -7,7 +7,10 @@
import pytest
-from lineageweave.embedding_backfill import backfill_post_content_embeddings
+from lineageweave.embedding_backfill import (
+ _SELECT_UNITS_SQL,
+ backfill_post_content_embeddings,
+)
class _Transaction:
@@ -148,6 +151,26 @@ def test_empty_selection_skips_provider_and_transaction() -> None:
assert conn.transaction_entries == 0
+def test_oversized_first_unit_reaches_the_explicit_failure_guard() -> None:
+ """The SQL cannot hide a blocking unit and silently stall later work."""
+ row = _row(0)
+ row["unit_text"] = "x" * 200
+
+ with pytest.raises(
+ ValueError,
+ match="one semantic unit exceeds the advertised embedding request ceiling",
+ ):
+ asyncio.run(
+ backfill_post_content_embeddings(
+ _Connection([row]),
+ _EmbeddingClient(),
+ max_request_body_bytes=100,
+ )
+ )
+
+ assert "candidate_ordinal = 1 or cumulative_text_bytes <= $1" in _SELECT_UNITS_SQL
+
+
def test_bulk_backfill_packs_largest_prefix_within_advertised_body_ceiling() -> None:
rows = [_row(0), _row(1), _row(2)]
conn = _Connection(rows)
From 20e1a5d958672587da01adb3abd577f5f1b92806 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 14:50:41 +0900
Subject: [PATCH 135/393] build(orchestrator): pin Rust embedding packer
---
docker/contextual-orchestrator/Dockerfile | 16 ++++++++++++++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
lineageweave/embedding_client.py | 3 +++
3 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 8bfa54618..70829ddac 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -1,3 +1,14 @@
+FROM python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf AS token-builder
+RUN apt-get update && apt-get install -y --no-install-recommends curl build-essential ca-certificates \
+ && rm -rf /var/lib/apt/lists/* \
+ && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
+ sh -s -- -y --profile minimal --default-toolchain 1.97.1
+ENV PATH=/root/.cargo/bin:$PATH
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/2c1a1fb5234d89a8dbbe59672358f6dd4c3e8691.tar.gz /tmp/contextual-orchestrator.tar.gz
+RUN mkdir /tmp/contextual-orchestrator \
+ && tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
+ && cargo build --locked --release --manifest-path /tmp/contextual-orchestrator/rust/token_counter/Cargo.toml
+
FROM python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf
WORKDIR /app
@@ -5,7 +16,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/d078ce6bc42b7ec37308bfded6adbe1d700c069c.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/2c1a1fb5234d89a8dbbe59672358f6dd4c3e8691.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
@@ -16,9 +27,10 @@ RUN mkdir /tmp/contextual-orchestrator \
'opentelemetry-api>=1.30.0' \
'opentelemetry-sdk>=1.30.0' \
'opentelemetry-exporter-otlp-proto-http>=1.30.0' \
- 'tiktoken>=0.11.0' \
&& useradd --uid 10001 --no-create-home orchestrator
+COPY --from=token-builder /tmp/contextual-orchestrator/rust/token_counter/target/release/contextual-token-counter /usr/local/bin/contextual-token-counter
+
COPY agents.json /app/agents.json
COPY start.py /app/start.py
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index ef34e8382..9fa9109e8 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `d078ce6bc42b7ec37308bfded6adbe1d700c069c`. The pin remains explicit
+commit `2c1a1fb5234d89a8dbbe59672358f6dd4c3e8691`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index 6170b2090..9f7082026 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -209,6 +209,9 @@ def batch_capabilities(self) -> dict[str, int]:
)
if any(type(response.get(key)) is not int or response[key] < 1 for key in required):
raise ValueError("embedding batch capabilities are incomplete")
+ model = response.get("model")
+ if isinstance(model, str) and model.strip():
+ self._bind_model(response)
return {key: int(response[key]) for key in required}
@property
From f20c0af69b02fe3b521592fd59476813a40401fc Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 14:52:03 +0900
Subject: [PATCH 136/393] fix(products): preserve primary evidence on
extraction failure
---
backend/app/post_content_worker.py | 30 +++++++-----
tests/test_post_content_worker.py | 79 ++++++++++++++++++++++++++++++
2 files changed, 98 insertions(+), 11 deletions(-)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index ed2624cb0..8e88ff661 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -505,17 +505,25 @@ async def process_post_content_job(
evidence_sources = await _operations_evidence_sources(
pool, post_id, row, vision_client
)
- await _persist_product_analysis_if_needed(
- pool,
- post_id,
- source_body_digest,
- row,
- vision_client,
- metadata["lineageweave_post_session_id"],
- settings.orchestrator_base_url,
- settings.orchestrator_api_key,
- evidence_sources,
- )
+ try:
+ await _persist_product_analysis_if_needed(
+ pool,
+ post_id,
+ source_body_digest,
+ row,
+ vision_client,
+ metadata["lineageweave_post_session_id"],
+ settings.orchestrator_base_url,
+ settings.orchestrator_api_key,
+ evidence_sources,
+ )
+ except (HttpClientError, OSError, RuntimeError, TimeoutError, ValueError) as exc:
+ _logger.error("product evidence ingestion failed for post_id=%s", post_id)
+ record_server_failure(
+ "product_semantic_ingestion",
+ exc,
+ outcome="provider_unavailable",
+ )
await _persist_operations_case_analysis_if_needed(
pool,
post_id,
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index c85086baf..17eba5d34 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -586,6 +586,85 @@ async def finish(_pool, _post_id, status, **_kwargs):
assert outcomes == [SUCCEEDED]
+def test_invalid_product_output_does_not_block_primary_post_evidence(monkeypatch) -> None:
+ """Optional product extraction cannot discard structure, embedding, or cases."""
+ outcomes: list[str] = []
+ persisted: list[str] = []
+ failures: list[tuple[str, str]] = []
+
+ async def claim(*_args, **_kwargs):
+ return _row(RUNNING, 1)
+
+ async def fail_product(*_args, **_kwargs):
+ raise RuntimeError("synthetic non-verbatim product span")
+
+ async def persist_cases(*_args, **_kwargs):
+ persisted.append("cases")
+
+ async def persist_content(*_args, **_kwargs):
+ persisted.append("content")
+
+ async def finish(_pool, _post_id, status, **_kwargs):
+ outcomes.append(status)
+
+ monkeypatch.setattr(post_content_worker, "_claim_job", claim)
+ monkeypatch.setattr(
+ post_content_worker,
+ "load_settings",
+ lambda: SimpleNamespace(
+ orchestrator_base_url="gateway", orchestrator_api_key="key"
+ ),
+ )
+ monkeypatch.setattr(
+ post_content_worker,
+ "_operations_evidence_sources",
+ lambda *_args, **_kwargs: asyncio.sleep(
+ 0,
+ result=(OperationsEvidenceSource("post-1", "Synthetic", "Evidence"),),
+ ),
+ )
+ monkeypatch.setattr(
+ post_content_worker, "_persist_product_analysis_if_needed", fail_product
+ )
+ monkeypatch.setattr(
+ post_content_worker,
+ "_persist_operations_case_analysis_if_needed",
+ persist_cases,
+ )
+ monkeypatch.setattr(post_content_worker, "normalize_post_body", lambda *_args: object())
+ monkeypatch.setattr(post_content_worker, "persist_post_content", persist_content)
+ monkeypatch.setattr(
+ post_content_worker,
+ "post_content_is_complete",
+ lambda *_args, **_kwargs: asyncio.sleep(0, result=True),
+ )
+ monkeypatch.setattr(
+ post_content_worker, "_requeue_project_missing_case_jobs", lambda *_args: asyncio.sleep(0)
+ )
+ monkeypatch.setattr(post_content_worker, "_finish_job", finish)
+ monkeypatch.setattr(
+ post_content_worker,
+ "record_server_failure",
+ lambda operation, _exc, *, outcome: failures.append((operation, outcome)),
+ )
+ client = SimpleNamespace(available=True, resolved_model="synthetic-model")
+
+ asyncio.run(
+ post_content_worker.process_post_content_job(
+ _Pool(_Connection()),
+ post_id="00000000-0000-0000-0000-000000000001",
+ source_body_digest="a" * 64,
+ vision_factory=lambda: client,
+ embedding_factory=lambda: client,
+ structure_factory=lambda: client,
+ )
+ )
+
+ assert persisted == ["cases", "content"]
+ assert outcomes == [SUCCEEDED]
+ assert failures == [("product_semantic_ingestion", "provider_unavailable")]
+
+
def test_case_analysis_persists_before_content_provider_failure(monkeypatch) -> None:
"""Independent case evidence survives a later structure or embedding outage."""
connection = _Connection(values=[False, 2])
From 4fb2a86c488f45afa41869170975552d16b6f77a Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 14:52:34 +0900
Subject: [PATCH 137/393] build(orchestrator): pin durable embedding shards
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 70829ddac..d8a0139d8 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/2c1a1fb5234d89a8dbbe59672358f6dd4c3e8691.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1fc4d3d563e8931b6414b2f6dc6453b82ba23a43.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cargo build --locked --release --manifest-path /tmp/contextual-orchestrator/rust/token_counter/Cargo.toml
@@ -16,7 +16,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/2c1a1fb5234d89a8dbbe59672358f6dd4c3e8691.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1fc4d3d563e8931b6414b2f6dc6453b82ba23a43.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 9fa9109e8..7f69a22b9 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `2c1a1fb5234d89a8dbbe59672358f6dd4c3e8691`. The pin remains explicit
+commit `1fc4d3d563e8931b6414b2f6dc6453b82ba23a43`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
From 2e3317bebcc1461b3dd6d78000132736cb61e5ec Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:00:12 +0900
Subject: [PATCH 138/393] build(orchestrator): pin PyO3 embedding packer
---
docker/contextual-orchestrator/Dockerfile | 10 ++++++----
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index d8a0139d8..1a7c6fe26 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,10 +4,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1fc4d3d563e8931b6414b2f6dc6453b82ba23a43.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/dadf843a974e40da4badf262a62598d475a96edf.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
- && cargo build --locked --release --manifest-path /tmp/contextual-orchestrator/rust/token_counter/Cargo.toml
+ && python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
+ && maturin build --locked --release --manifest-path /tmp/contextual-orchestrator/rust/token_counter/Cargo.toml --out /tmp/token-wheels
FROM python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf
@@ -16,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1fc4d3d563e8931b6414b2f6dc6453b82ba23a43.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/dadf843a974e40da4badf262a62598d475a96edf.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
@@ -29,7 +30,8 @@ RUN mkdir /tmp/contextual-orchestrator \
'opentelemetry-exporter-otlp-proto-http>=1.30.0' \
&& useradd --uid 10001 --no-create-home orchestrator
-COPY --from=token-builder /tmp/contextual-orchestrator/rust/token_counter/target/release/contextual-token-counter /usr/local/bin/contextual-token-counter
+COPY --from=token-builder /tmp/token-wheels /tmp/token-wheels
+RUN pip install --no-cache-dir /tmp/token-wheels/*.whl && rm -rf /tmp/token-wheels
COPY agents.json /app/agents.json
COPY start.py /app/start.py
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 7f69a22b9..52962aac0 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `1fc4d3d563e8931b6414b2f6dc6453b82ba23a43`. The pin remains explicit
+commit `dadf843a974e40da4badf262a62598d475a96edf`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
From a1b22d944c72a59cd07948339fef34aaa07be784 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:01:31 +0900
Subject: [PATCH 139/393] test(workers): run Ask settlement through dedicated
consumer
---
backend/tests/test_api.py | 37 ++++++++++++++++++++++++-----
tests/test_documentation_hygiene.py | 2 +-
2 files changed, 32 insertions(+), 7 deletions(-)
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 0d10021b8..43bc013a0 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -804,6 +804,31 @@ def client(seeded_db):
yield test_client
+@pytest.fixture
+def client_with_ask_worker(client):
+ """Run the production Ask consumer beside API tests that require settlement."""
+ from backend.app import main as main_module
+ from backend.app.config import load_settings
+ from backend.app.global_ask_queue import run_global_ask_worker
+
+ async def run_worker() -> None:
+ await run_global_ask_worker(
+ main_module.app.state.valkey,
+ main_module.app.state.pool,
+ chat_factory=lambda: main_module._post_chat_client(
+ timeout=load_settings().orchestrator_answer_timeout_seconds
+ ),
+ embedding_factory=lambda: main_module._embedding_client(),
+ )
+
+ assert client.portal is not None
+ worker = client.portal.start_task_soon(run_worker)
+ try:
+ yield client
+ finally:
+ worker.cancel()
+
+
def test_keyverse_account_resolves_exact_scope_and_role_intersection(
monkeypatch: pytest.MonkeyPatch, seeded_db, demo_analyst_token
) -> None:
@@ -3974,7 +3999,7 @@ def answer(self, question: str, sources) -> object:
def test_global_ask_provider_error_does_not_leak_raw_error(
- client, demo_analyst_token, seeded_db, monkeypatch
+ client_with_ask_worker, demo_analyst_token, seeded_db, monkeypatch
) -> None:
"""The cross-post Ask boundary settles a provider failure with a
stable message, not the worker's raw exception text (ADR 0123).
@@ -4002,7 +4027,7 @@ async def _source(*_args, **_kwargs):
monkeypatch.setattr("backend.app.main._post_chat_client", lambda **_kwargs: _FailingAskClient())
headers = {"Authorization": f"Bearer {demo_analyst_token}"}
- submitted = client.post(
+ submitted = client_with_ask_worker.post(
"/api/ask",
json={"question": "What happened in this global failure case?"},
headers=headers,
@@ -4013,7 +4038,7 @@ async def _source(*_args, **_kwargs):
deadline = _time.monotonic() + 30
body: dict = {}
while _time.monotonic() < deadline:
- polled = client.get(f"/api/ask/jobs/{job_id}", headers=headers)
+ polled = client_with_ask_worker.get(f"/api/ask/jobs/{job_id}", headers=headers)
assert polled.status_code == 200
body = polled.json()
if body["job_status_code"] in ("succeeded", "failed"):
@@ -5127,7 +5152,7 @@ def test_ask_requires_authentication(client) -> None:
def test_ask_queues_a_job_and_polls_it_to_a_settled_answer(
- client, demo_analyst_token, seeded_db, monkeypatch
+ client_with_ask_worker, demo_analyst_token, seeded_db, monkeypatch
) -> None:
"""Submission returns 202 immediately; the worker settles the job.
@@ -5162,7 +5187,7 @@ async def _source(*_args, **_kwargs):
"backend.app.global_ask_queue.compute_global_ask_answer", _fake_compute_answer
)
headers = {"Authorization": f"Bearer {demo_analyst_token}"}
- submitted = client.post(
+ submitted = client_with_ask_worker.post(
"/api/ask", json={"question": "What happened with the public post?"}, headers=headers
)
assert submitted.status_code == 202
@@ -5172,7 +5197,7 @@ async def _source(*_args, **_kwargs):
deadline = _time.monotonic() + 30
body: dict = {}
while _time.monotonic() < deadline:
- polled = client.get(f"/api/ask/jobs/{job_id}", headers=headers)
+ polled = client_with_ask_worker.get(f"/api/ask/jobs/{job_id}", headers=headers)
assert polled.status_code == 200
body = polled.json()
if body["job_status_code"] in ("succeeded", "failed"):
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index 9de8e2ba6..e69e64cbc 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "2b11707e828c8d3f2e6cdf4b532172bf1797f786"
+ expected_embedding_contract_commit = "dadf843a974e40da4badf262a62598d475a96edf"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 8fa7e9bf006cc886893c00c234575cb1c8359cdb Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:02:46 +0900
Subject: [PATCH 140/393] fix(orchestrator): bind configured OpenAI embedding
authority
---
docker/contextual-orchestrator/start.py | 7 ++++---
tests/test_contextual_orchestrator_start.py | 9 +++++----
2 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 07fa48cc1..53b50c8be 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -70,8 +70,7 @@ def main() -> None:
agent["credential_key"] = "LLM_GATEWAY_API_KEY"
agent.setdefault("provider_protocol", "auto")
if embedding_model:
- agents["agents"].append(
- {
+ embedding_agent = {
"id": "gateway_embedding_agent",
"model": embedding_model,
"provider_protocol": "auto",
@@ -80,7 +79,9 @@ def main() -> None:
"tags": ["embedding"],
"priority": 1,
}
- )
+ if embedding_model == "text-embedding-3-large":
+ embedding_agent["provider_name"] = "openai"
+ agents["agents"].append(embedding_agent)
agents_path.write_text(json.dumps(agents), encoding="utf-8")
from contextual_orchestrator.credentials import register_credential
diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py
index 4830dc4c6..668485a6b 100644
--- a/tests/test_contextual_orchestrator_start.py
+++ b/tests/test_contextual_orchestrator_start.py
@@ -65,7 +65,7 @@ def test_provider_key_is_not_aliased_as_gateway_transport(monkeypatch) -> None:
module.main()
-@pytest.mark.parametrize("embedding_model", ["embedding-model", ""])
+@pytest.mark.parametrize("embedding_model", ["embedding-model", "text-embedding-3-large", ""])
def test_bootstrap_registers_configured_remote_embedding_agent(
monkeypatch, embedding_model: str
) -> None:
@@ -148,8 +148,7 @@ def serve() -> None:
agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])
]
if embedding_model:
- assert embedding_agents == [
- {
+ expected = {
"id": "gateway_embedding_agent",
"model": embedding_model,
"provider_protocol": "auto",
@@ -158,7 +157,9 @@ def serve() -> None:
"tags": ["embedding"],
"priority": 1,
}
- ]
+ if embedding_model == "text-embedding-3-large":
+ expected["provider_name"] = "openai"
+ assert embedding_agents == [expected]
else:
assert embedding_agents == []
assert "LLM_GATEWAY_EMBEDDING_MODEL" not in os.environ
From adab218e6a775e3268fac868e25fc0c8947b19dc Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:16:47 +0900
Subject: [PATCH 141/393] fix(runtime): pin hardened durable embedding gateway
---
docker-compose.yml | 4 ++++
docker/contextual-orchestrator/Dockerfile | 4 ++--
docker/contextual-orchestrator/start.py | 4 ++++
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
tests/test_contextual_orchestrator_start.py | 3 +++
5 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index 27a783fe3..0d8d247fe 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -119,11 +119,15 @@ services:
# explicit bounded 8 MiB limit rather than an unbounded request size.
CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES: ${CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES:-8388608}
CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS: ${CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS:-host.docker.internal}
+ BATCH_JOB_REGISTRY_VALKEY_URL: redis://valkey:6379/1
OTEL_SERVICE_NAME: ${OTEL_ORCHESTRATOR_SERVICE_NAME:-contextual-orchestrator}
# Do not set OTEL_EXPORTER_OTLP_ENDPOINT here. An empty
# ${OTEL_EXPORTER_OTLP_ENDPOINT:-} interpolation would wipe a value from
# env_file (${HOME}/.env). Export stays opt-in from that file or the host.
command: ["python", "/app/start.py"]
+ depends_on:
+ valkey:
+ condition: service_healthy
ports:
- "${ORCHESTRATOR_PORT:-18000}:8000"
healthcheck:
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 1a7c6fe26..fe7a186d8 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/dadf843a974e40da4badf262a62598d475a96edf.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/5ef299de0d4736723157b3c1cb1dd4de12a4c125.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/dadf843a974e40da4badf262a62598d475a96edf.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/5ef299de0d4736723157b3c1cb1dd4de12a4c125.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 53b50c8be..0f3687d24 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -49,6 +49,7 @@ def main() -> None:
if not provider_url.rstrip("/").endswith("/v1"):
provider_url = provider_url.rstrip("/") + "/v1"
embedding_model = os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", "").strip()
+ batch_registry_url = os.environ.pop("BATCH_JOB_REGISTRY_VALKEY_URL", "").strip()
raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
try:
max_output_tokens = int(raw_limit)
@@ -87,9 +88,12 @@ def main() -> None:
from contextual_orchestrator.credentials import register_credential
register_credential("LLM_GATEWAY_API_KEY", gateway_key)
+ if batch_registry_url:
+ register_credential("batch_job_registry_valkey_url", batch_registry_url)
for credential_name, credential_value in provider_credentials.items():
register_credential(credential_name, credential_value)
del gateway_key
+ del batch_registry_url
del provider_credentials
sys.argv = [
"contextual_orchestrator",
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 52962aac0..df96558a1 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `dadf843a974e40da4badf262a62598d475a96edf`. The pin remains explicit
+commit `5ef299de0d4736723157b3c1cb1dd4de12a4c125`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py
index 668485a6b..844c90d07 100644
--- a/tests/test_contextual_orchestrator_start.py
+++ b/tests/test_contextual_orchestrator_start.py
@@ -114,6 +114,7 @@ def serve() -> None:
monkeypatch.setenv("BYTEZ_API_KEY", "bytez-key")
monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_TOKEN", "orchestrator-token")
monkeypatch.setenv("LLM_GATEWAY_API_URL", "https://gateway.example")
+ monkeypatch.setenv("BATCH_JOB_REGISTRY_VALKEY_URL", "redis://valkey:6379/1")
if embedding_model:
monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_MODEL", embedding_model)
else:
@@ -127,6 +128,7 @@ def serve() -> None:
assert "--embedding-model" not in argv
assert captured["credentials"] == [
("LLM_GATEWAY_API_KEY", "provider-key"),
+ ("batch_job_registry_valkey_url", "redis://valkey:6379/1"),
("OPENAI_API_KEY", "openai-key"),
("OPENROUTER_API_KEY", "openrouter-key"),
("NVIDIA_NIM_API_KEY", "nim-key"),
@@ -141,6 +143,7 @@ def serve() -> None:
"NVIDIA_NIM_API_KEY",
"NVIDIA_NIM_API_KEY_SUB",
"BYTEZ_API_KEY",
+ "BATCH_JOB_REGISTRY_VALKEY_URL",
} & os.environ.keys()
agents = captured["agents"]
assert isinstance(agents, dict)
From e3fc11aa0bb90d3da8f6c7cc0b76a3fc1f79d1b7 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:17:53 +0900
Subject: [PATCH 142/393] fix(runtime): colocate PyO3 extension with source
package
---
docker/contextual-orchestrator/Dockerfile | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index fe7a186d8..40503b433 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -31,7 +31,9 @@ RUN mkdir /tmp/contextual-orchestrator \
&& useradd --uid 10001 --no-create-home orchestrator
COPY --from=token-builder /tmp/token-wheels /tmp/token-wheels
-RUN pip install --no-cache-dir /tmp/token-wheels/*.whl && rm -rf /tmp/token-wheels
+RUN pip install --no-cache-dir /tmp/token-wheels/*.whl \
+ && cp /usr/local/lib/python3.12/site-packages/contextual_orchestrator/_token_packer*.so /app/contextual_orchestrator/ \
+ && rm -rf /tmp/token-wheels
COPY agents.json /app/agents.json
COPY start.py /app/start.py
From e252116b5e1843ebebac87cfe679ce2262ed09a4 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:18:35 +0900
Subject: [PATCH 143/393] fix(orchestrator): keep embedding selection upstream
---
docker/contextual-orchestrator/start.py | 16 +---------
.../0030-external-llm-gateway-environment.md | 18 +++++-------
tests/test_contextual_orchestrator_start.py | 29 ++-----------------
tests/test_documentation_hygiene.py | 9 +++---
4 files changed, 15 insertions(+), 57 deletions(-)
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 0f3687d24..3239ae616 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -48,7 +48,7 @@ def main() -> None:
raise SystemExit("LLM_GATEWAY_API_URL or LLM_GATEWAY_URL is required to start the gateway")
if not provider_url.rstrip("/").endswith("/v1"):
provider_url = provider_url.rstrip("/") + "/v1"
- embedding_model = os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", "").strip()
+ os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", None)
batch_registry_url = os.environ.pop("BATCH_JOB_REGISTRY_VALKEY_URL", "").strip()
raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
try:
@@ -70,19 +70,6 @@ def main() -> None:
agent["base_url"] = provider_url
agent["credential_key"] = "LLM_GATEWAY_API_KEY"
agent.setdefault("provider_protocol", "auto")
- if embedding_model:
- embedding_agent = {
- "id": "gateway_embedding_agent",
- "model": embedding_model,
- "provider_protocol": "auto",
- "base_url": provider_url,
- "credential_key": "LLM_GATEWAY_API_KEY",
- "tags": ["embedding"],
- "priority": 1,
- }
- if embedding_model == "text-embedding-3-large":
- embedding_agent["provider_name"] = "openai"
- agents["agents"].append(embedding_agent)
agents_path.write_text(json.dumps(agents), encoding="utf-8")
from contextual_orchestrator.credentials import register_credential
@@ -114,7 +101,6 @@ def main() -> None:
str(max_body_bytes),
]
del provider_url
- del embedding_model
del auth_token
from contextual_orchestrator.__main__ import main as serve
diff --git a/docs/adr/0030-external-llm-gateway-environment.md b/docs/adr/0030-external-llm-gateway-environment.md
index 58910df4b..890c65586 100644
--- a/docs/adr/0030-external-llm-gateway-environment.md
+++ b/docs/adr/0030-external-llm-gateway-environment.md
@@ -75,17 +75,13 @@ must never be returned through a buyer-facing API or persisted failure detail.
When they are blank, contextual-orchestrator resolves the registered agent
model, so a local or provider-specific model name cannot leak into this
application or be assumed available on an external gateway.
-- LineageWeave embedding requests do not select a model: every batch omits
- `model`. At the Compose process boundary an operator-supplied
- `LLM_GATEWAY_EMBEDDING_MODEL` may register one explicit remote agent tagged
- `embedding` in contextual-orchestrator, using the same provider URL and
- credential handle as the gateway. The bootstrap removes that environment
- value before serving; application code never reads it, sends it in a request,
- or calls the provider directly. contextual-orchestrator returns the selected
- identity on submission and polling responses. LineageWeave binds that
- identity for later batches and persists it with every vector. A missing or
- changed identity, or an incomplete vector batch, fails closed and cannot make
- post content complete.
+- LineageWeave does not configure an embedding model. Its first batch request
+ omits `model`; contextual-orchestrator selects a provider-neutral embedding
+ model from its discovered provider catalog and returns that identity on
+ submission and polling responses. LineageWeave binds that identity for later
+ batches and persists it with every vector. A missing or changed identity, or
+ an incomplete vector batch, fails closed and cannot make post content
+ complete.
- `LLM_API_KEY`, `LLM_API_GATEWAY`, and `LLM_GATEWAY_URL` are compatibility
aliases only; `LLM_GATEWAY_API_KEY` and `LLM_GATEWAY_API_URL` are the
canonical names for
diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py
index 844c90d07..7526bd38a 100644
--- a/tests/test_contextual_orchestrator_start.py
+++ b/tests/test_contextual_orchestrator_start.py
@@ -65,10 +65,7 @@ def test_provider_key_is_not_aliased_as_gateway_transport(monkeypatch) -> None:
module.main()
-@pytest.mark.parametrize("embedding_model", ["embedding-model", "text-embedding-3-large", ""])
-def test_bootstrap_registers_configured_remote_embedding_agent(
- monkeypatch, embedding_model: str
-) -> None:
+def test_bootstrap_leaves_embedding_selection_to_the_orchestrator(monkeypatch) -> None:
module = _load_start_module()
captured: dict[str, object] = {}
@@ -115,10 +112,7 @@ def serve() -> None:
monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_TOKEN", "orchestrator-token")
monkeypatch.setenv("LLM_GATEWAY_API_URL", "https://gateway.example")
monkeypatch.setenv("BATCH_JOB_REGISTRY_VALKEY_URL", "redis://valkey:6379/1")
- if embedding_model:
- monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_MODEL", embedding_model)
- else:
- monkeypatch.delenv("LLM_GATEWAY_EMBEDDING_MODEL", raising=False)
+ monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_MODEL", "text-embedding-3-large")
module.main()
@@ -147,22 +141,5 @@ def serve() -> None:
} & os.environ.keys()
agents = captured["agents"]
assert isinstance(agents, dict)
- embedding_agents = [
- agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])
- ]
- if embedding_model:
- expected = {
- "id": "gateway_embedding_agent",
- "model": embedding_model,
- "provider_protocol": "auto",
- "base_url": "https://gateway.example/v1",
- "credential_key": "LLM_GATEWAY_API_KEY",
- "tags": ["embedding"],
- "priority": 1,
- }
- if embedding_model == "text-embedding-3-large":
- expected["provider_name"] = "openai"
- assert embedding_agents == [expected]
- else:
- assert embedding_agents == []
+ assert not [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])]
assert "LLM_GATEWAY_EMBEDDING_MODEL" not in os.environ
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index e69e64cbc..b5851a425 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "dadf843a974e40da4badf262a62598d475a96edf"
+ expected_embedding_contract_commit = "5ef299de0d4736723157b3c1cb1dd4de12a4c125"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
@@ -134,10 +134,9 @@ def test_orchestrator_runtime_pin_matches_adr() -> None:
def test_embedding_bootstrap_contract_keeps_request_model_free() -> None:
- """ADR distinguishes remote-agent registration from request selection."""
+ """ADR assigns embedding discovery and selection to the orchestrator."""
adr = (_ADR_DIRECTORY / "0030-external-llm-gateway-environment.md").read_text(
encoding="utf-8"
)
- assert "LineageWeave embedding requests do not select a model" in adr
- assert "LLM_GATEWAY_EMBEDDING_MODEL" in adr
- assert "application code never reads it" in adr
+ assert "does not configure an embedding model" in adr
+ assert "discovered provider catalog" in adr
From afcb731757887146861783ea8923ace543f1e419 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:23:00 +0900
Subject: [PATCH 144/393] fix(security): audit product evidence SQL fragment
---
backend/app/main.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/backend/app/main.py b/backend/app/main.py
index 981ffce85..1388387c4 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1590,7 +1590,8 @@ async def read_post(
project_evidence = await _load_project_evidence(
conn, post_id, row["source_project_code"], row["source_project_name"]
)
- product_rows = await conn.fetch(
+ # Safe SQL: the eligibility predicate is an immutable schema fragment; post id is bound.
+ product_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
"select mention.mention_ordinal, mention.extracted_product_name, "
"mention.resolution_status_code, catalog.canonical_product_name, "
"catalog.product_level_code, mention.evidence_text, "
From d5d38970a667c7fa7871c9626b6c33470135bb06 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:24:37 +0900
Subject: [PATCH 145/393] fix(backfill): bound durable jobs by provider input
contract
---
docker/contextual-orchestrator/Dockerfile | 1 +
lineageweave/embedding_backfill.py | 8 ++++++--
scripts/backfill_post_embeddings.py | 1 +
tests/test_embedding_backfill.py | 11 ++++++-----
4 files changed, 14 insertions(+), 7 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 40503b433..81ed6c8b2 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -28,6 +28,7 @@ RUN mkdir /tmp/contextual-orchestrator \
'opentelemetry-api>=1.30.0' \
'opentelemetry-sdk>=1.30.0' \
'opentelemetry-exporter-otlp-proto-http>=1.30.0' \
+ 'redis>=5.0' \
&& useradd --uid 10001 --no-create-home orchestrator
COPY --from=token-builder /tmp/token-wheels /tmp/token-wheels
diff --git a/lineageweave/embedding_backfill.py b/lineageweave/embedding_backfill.py
index e128333e9..3eefdffcc 100644
--- a/lineageweave/embedding_backfill.py
+++ b/lineageweave/embedding_backfill.py
@@ -32,7 +32,8 @@
)
)
select * from candidates
- where candidate_ordinal = 1 or cumulative_text_bytes <= $1
+ where candidate_ordinal = 1
+ or (cumulative_text_bytes <= $1 and candidate_ordinal <= $2)
order by cumulative_text_bytes
"""
@@ -42,6 +43,7 @@ async def backfill_post_content_embeddings(
embedding_client: ContextualOrchestratorEmbeddingClient,
*,
max_request_body_bytes: int,
+ max_inputs: int,
) -> dict[str, int | str]:
"""Embed one explicitly bounded unit set and atomically persist the complete batch.
@@ -52,7 +54,9 @@ async def backfill_post_content_embeddings(
"""
if max_request_body_bytes < 1:
raise ValueError("max_request_body_bytes must be positive")
- rows = list(await conn.fetch(_SELECT_UNITS_SQL, max_request_body_bytes))
+ if max_inputs < 1:
+ raise ValueError("max_inputs must be positive")
+ rows = list(await conn.fetch(_SELECT_UNITS_SQL, max_request_body_bytes, max_inputs))
if not rows:
return {"selected_units": 0, "persisted_units": 0, "dimension_values": 0}
diff --git a/scripts/backfill_post_embeddings.py b/scripts/backfill_post_embeddings.py
index f713183f7..a26677053 100755
--- a/scripts/backfill_post_embeddings.py
+++ b/scripts/backfill_post_embeddings.py
@@ -46,6 +46,7 @@ async def _run(target_dsn: str) -> dict[str, int | str]:
conn,
client,
max_request_body_bytes=capabilities["max_request_body_bytes"],
+ max_inputs=capabilities["max_inputs"],
)
finally:
await conn.close()
diff --git a/tests/test_embedding_backfill.py b/tests/test_embedding_backfill.py
index 4adb7134d..8a21c950b 100644
--- a/tests/test_embedding_backfill.py
+++ b/tests/test_embedding_backfill.py
@@ -99,7 +99,7 @@ def test_bulk_backfill_calls_provider_once_and_persists_in_one_transaction() ->
client = _EmbeddingClient()
result = asyncio.run(
- backfill_post_content_embeddings(conn, client, max_request_body_bytes=10_000)
+ backfill_post_content_embeddings(conn, client, max_request_body_bytes=10_000, max_inputs=2048)
)
assert result == {
@@ -126,7 +126,7 @@ def test_provider_failure_makes_no_database_change() -> None:
with pytest.raises(RuntimeError, match="synthetic provider failure"):
asyncio.run(
- backfill_post_content_embeddings(conn, client, max_request_body_bytes=10_000)
+ backfill_post_content_embeddings(conn, client, max_request_body_bytes=10_000, max_inputs=2048)
)
assert conn.transaction_entries == 0
@@ -139,7 +139,7 @@ def test_empty_selection_skips_provider_and_transaction() -> None:
client = _EmbeddingClient()
result = asyncio.run(
- backfill_post_content_embeddings(conn, client, max_request_body_bytes=10_000)
+ backfill_post_content_embeddings(conn, client, max_request_body_bytes=10_000, max_inputs=2048)
)
assert result == {
@@ -165,10 +165,11 @@ def test_oversized_first_unit_reaches_the_explicit_failure_guard() -> None:
_Connection([row]),
_EmbeddingClient(),
max_request_body_bytes=100,
+ max_inputs=2048,
)
)
- assert "candidate_ordinal = 1 or cumulative_text_bytes <= $1" in _SELECT_UNITS_SQL
+ assert "candidate_ordinal <= $2" in _SELECT_UNITS_SQL
def test_bulk_backfill_packs_largest_prefix_within_advertised_body_ceiling() -> None:
@@ -181,7 +182,7 @@ def test_bulk_backfill_packs_largest_prefix_within_advertised_body_ceiling() ->
result = asyncio.run(
backfill_post_content_embeddings(
- conn, client, max_request_body_bytes=two_input_size
+ conn, client, max_request_body_bytes=two_input_size, max_inputs=2048
)
)
From 9e6050f45fb58aa98f6c6e4ae76d3bc8a1b79477 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:25:50 +0900
Subject: [PATCH 146/393] fix(backfill): consume advertised input ceiling
---
lineageweave/embedding_client.py | 2 ++
tests/test_embedding_client_edges.py | 2 ++
2 files changed, 4 insertions(+)
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index 9f7082026..4e198028d 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -202,6 +202,8 @@ def batch_capabilities(self) -> dict[str, int]:
)
required = (
"max_request_body_bytes",
+ "max_inputs",
+ "max_total_tokens",
"max_tokens_per_part",
"max_chars_per_part",
"poll_after_ms",
diff --git a/tests/test_embedding_client_edges.py b/tests/test_embedding_client_edges.py
index c08151b43..b509a2265 100644
--- a/tests/test_embedding_client_edges.py
+++ b/tests/test_embedding_client_edges.py
@@ -37,6 +37,8 @@ def test_batch_capabilities_require_positive_integer_limits(
"get_json",
lambda *_args, **_kwargs: {
"max_request_body_bytes": 65_536,
+ "max_inputs": 2048,
+ "max_total_tokens": 300_000,
"max_tokens_per_part": 280_000,
"max_chars_per_part": 240_000,
"poll_after_ms": 1_000,
From d676056f6581f650640732a9ddd80e1a87e12bdf Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:27:00 +0900
Subject: [PATCH 147/393] fix(orchestrator): propagate post session in payload
---
.../0071-post-scoped-llm-session-metadata.md | 9 ++-
docs/adr/0122-otel-session-observability.md | 10 ++-
lineageweave/http_client.py | 23 +++++-
tests/test_llm_context.py | 79 +++++++++++++++++++
4 files changed, 111 insertions(+), 10 deletions(-)
diff --git a/docs/adr/0071-post-scoped-llm-session-metadata.md b/docs/adr/0071-post-scoped-llm-session-metadata.md
index 44aa14e4d..a4fccc6a1 100644
--- a/docs/adr/0071-post-scoped-llm-session-metadata.md
+++ b/docs/adr/0071-post-scoped-llm-session-metadata.md
@@ -7,9 +7,10 @@
Every contextual-orchestrator request made about one post carries the same
deterministic `lineageweave_post_session_id` in the existing OpenAI-compatible
-`metadata` object. The ID is derived from `post_id` with a LineageWeave-only
-UUID namespace; it is not a database key and does not require a
-`user_account + post_id` table.
+`metadata` object and, for POST requests, as the top-level orchestrator
+`session_id`. The correlation header defined by ADR 0122 carries that same
+value. The ID is derived from `post_id` with a LineageWeave-only UUID namespace;
+it is not a database key and does not require a `user_account + post_id` table.
The same metadata object carries non-body provenance hints when available:
PU, author account ID, corporate-entity code, and source author/company,
@@ -30,3 +31,5 @@ be implemented by runtime monkey patching or by reusing a workflow run ID.
not an implicit conversation-memory store.
- Posts without a post scope, such as global Ask Agent, do not receive a fake
post session ID.
+- Provider-neutral payloads sent to services other than contextual-orchestrator
+ do not receive the orchestrator-only top-level `session_id` field.
diff --git a/docs/adr/0122-otel-session-observability.md b/docs/adr/0122-otel-session-observability.md
index 01865559d..972275bfe 100644
--- a/docs/adr/0122-otel-session-observability.md
+++ b/docs/adr/0122-otel-session-observability.md
@@ -27,10 +27,12 @@ must not be cited as protected organization evidence.
endpoint leaves the SDK unconfigured so a later operator value can still
enable export.
2. Every contextual-orchestrator POST carries the existing
- `lineageweave_post_session_id` as `X-LineageWeave-Session-Id`. The
- orchestrator binds it to the request context and adds it to provider spans,
- so chat, Responses, structured output, VISION, and embedding work for one
- post can be investigated together.
+ `lineageweave_post_session_id` as both the top-level payload `session_id`
+ and `X-LineageWeave-Session-Id`. The orchestrator binds it to the request
+ context and adds it to provider spans, so chat, Responses, structured
+ output, VISION, and embedding work for one post can be investigated
+ together. The post identifier remains authorized provenance metadata and
+ is not copied into the public response.
3. LineageWeave emits bounded HTTP and Valkey operation spans. HTTP client
failures follow the OpenTelemetry HTTP semantic conventions: error
responses and invalid response bodies end the client span with an error.
diff --git a/lineageweave/http_client.py b/lineageweave/http_client.py
index d1791cd05..725b2d337 100644
--- a/lineageweave/http_client.py
+++ b/lineageweave/http_client.py
@@ -34,8 +34,16 @@ class HttpClientError(RuntimeError):
"""The remote endpoint failed, returned a non-success status, or invalid JSON."""
-def json_request_body(payload: dict) -> bytes:
- """Serialize the exact JSON body sent by :func:`post_json`."""
+def json_request_body(
+ payload: dict,
+ *,
+ include_orchestrator_session: bool = False,
+) -> bytes:
+ """Serialize a JSON body with bounded post provenance when requested.
+
+ ``session_id`` is an orchestrator transport field, so callers that only
+ size or persist a provider-neutral payload retain their existing bytes.
+ """
request_payload = payload
request_metadata = current_llm_metadata()
if request_metadata:
@@ -47,6 +55,10 @@ def json_request_body(payload: dict) -> bytes:
request_payload["metadata"] = {**existing_metadata, **request_metadata}
else:
raise ValueError("metadata must be an object")
+ if include_orchestrator_session:
+ session_id = request_metadata.get("lineageweave_post_session_id")
+ if session_id:
+ request_payload["session_id"] = session_id
return json.dumps(request_payload).encode("utf-8")
@@ -276,7 +288,12 @@ def post_json(
status, raw = _request(
"POST",
url,
- body=json_request_body(payload),
+ body=json_request_body(
+ payload,
+ include_orchestrator_session=(
+ service_peer_name == "contextual-orchestrator"
+ ),
+ ),
headers=request_headers,
timeout=timeout,
)
diff --git a/tests/test_llm_context.py b/tests/test_llm_context.py
index 0dc6c21d0..402b4a72a 100644
--- a/tests/test_llm_context.py
+++ b/tests/test_llm_context.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import json
+
import lineageweave.http_client as http_client
from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata
@@ -39,3 +41,80 @@ def fake_request(method, url, *, body, headers, timeout):
assert seen["payload"]
assert "lineageweave_post_session_id" in seen["payload"].decode("utf-8")
assert "lineageweave_pu" in seen["payload"].decode("utf-8")
+
+
+def test_orchestrator_session_is_stable_across_modalities_and_retries(monkeypatch) -> None:
+ """One post uses one payload session for chat, VISION, and embeddings."""
+ requests: list[tuple[str, dict[str, object], dict[str, str]]] = []
+
+ def fake_request(method, url, *, body, headers, timeout):
+ del method, timeout
+ requests.append((url, json.loads(body), headers))
+ return 200, b'{"choices": []}'
+
+ monkeypatch.setattr(http_client, "_request", fake_request)
+ response_payload = {"choices": []}
+ first = build_post_llm_metadata("synthetic-post-1", {})
+ second = build_post_llm_metadata("synthetic-post-2", {})
+
+ with use_llm_metadata(first):
+ for path in (
+ "/v1/chat/completions",
+ "/v1/vision/structured",
+ "/v1/batch/embeddings",
+ "/v1/chat/completions",
+ ):
+ assert http_client.post_json(
+ f"https://orchestrator.example{path}",
+ {"input": []},
+ headers={},
+ timeout=1,
+ ) == response_payload
+ with use_llm_metadata(second):
+ http_client.post_json(
+ "https://orchestrator.example/v1/chat/completions",
+ {"input": []},
+ headers={},
+ timeout=1,
+ )
+
+ first_session = first["lineageweave_post_session_id"]
+ assert {request[1]["session_id"] for request in requests[:4]} == {first_session}
+ assert {request[2]["x-lineageweave-session-id"] for request in requests[:4]} == {
+ first_session
+ }
+ assert all(
+ request[1]["metadata"]["lineageweave_post_id"] == "synthetic-post-1"
+ for request in requests[:4]
+ )
+ assert requests[4][1]["session_id"] == second["lineageweave_post_session_id"]
+ assert requests[4][1]["session_id"] != first_session
+
+
+def test_orchestrator_session_is_not_invented_or_sent_to_other_peers(monkeypatch) -> None:
+ """Missing post context and non-orchestrator calls retain their payloads."""
+ bodies: list[dict[str, object]] = []
+
+ def fake_request(method, url, *, body, headers, timeout):
+ del method, url, headers, timeout
+ bodies.append(json.loads(body))
+ return 200, b"{}"
+
+ monkeypatch.setattr(http_client, "_request", fake_request)
+ http_client.post_json(
+ "https://orchestrator.example/v1/chat/completions",
+ {"messages": []},
+ headers={},
+ timeout=1,
+ )
+ with use_llm_metadata(build_post_llm_metadata("synthetic-post", {})):
+ http_client.post_json(
+ "https://tepp.example/v1/measurements",
+ {"observations": []},
+ headers={},
+ timeout=1,
+ service_peer_name="tepp",
+ )
+
+ assert "session_id" not in bodies[0]
+ assert "session_id" not in bodies[1]
From bb0b9ce2fa41030abbcb2789222d7f2691e0a6a6 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:27:16 +0900
Subject: [PATCH 148/393] fix(runtime): restore configured embedding capability
---
docker/contextual-orchestrator/start.py | 16 +++++++++++++++-
tests/test_contextual_orchestrator_start.py | 15 +++++++++++++--
2 files changed, 28 insertions(+), 3 deletions(-)
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 3239ae616..725f75540 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -48,7 +48,7 @@ def main() -> None:
raise SystemExit("LLM_GATEWAY_API_URL or LLM_GATEWAY_URL is required to start the gateway")
if not provider_url.rstrip("/").endswith("/v1"):
provider_url = provider_url.rstrip("/") + "/v1"
- os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", None)
+ embedding_model = os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", "").strip()
batch_registry_url = os.environ.pop("BATCH_JOB_REGISTRY_VALKEY_URL", "").strip()
raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
try:
@@ -70,6 +70,19 @@ def main() -> None:
agent["base_url"] = provider_url
agent["credential_key"] = "LLM_GATEWAY_API_KEY"
agent.setdefault("provider_protocol", "auto")
+ if embedding_model:
+ embedding_agent = {
+ "id": "gateway_embedding_agent",
+ "model": embedding_model,
+ "provider_protocol": "auto",
+ "base_url": provider_url,
+ "credential_key": "LLM_GATEWAY_API_KEY",
+ "tags": ["embedding"],
+ "priority": 1,
+ }
+ if embedding_model == "text-embedding-3-large":
+ embedding_agent["provider_name"] = "openai"
+ agents["agents"].append(embedding_agent)
agents_path.write_text(json.dumps(agents), encoding="utf-8")
from contextual_orchestrator.credentials import register_credential
@@ -101,6 +114,7 @@ def main() -> None:
str(max_body_bytes),
]
del provider_url
+ del embedding_model
del auth_token
from contextual_orchestrator.__main__ import main as serve
diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py
index 7526bd38a..bf63aca42 100644
--- a/tests/test_contextual_orchestrator_start.py
+++ b/tests/test_contextual_orchestrator_start.py
@@ -65,7 +65,7 @@ def test_provider_key_is_not_aliased_as_gateway_transport(monkeypatch) -> None:
module.main()
-def test_bootstrap_leaves_embedding_selection_to_the_orchestrator(monkeypatch) -> None:
+def test_bootstrap_registers_operator_configured_embedding_capability(monkeypatch) -> None:
module = _load_start_module()
captured: dict[str, object] = {}
@@ -141,5 +141,16 @@ def serve() -> None:
} & os.environ.keys()
agents = captured["agents"]
assert isinstance(agents, dict)
- assert not [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])]
+ assert [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])] == [
+ {
+ "id": "gateway_embedding_agent",
+ "model": "text-embedding-3-large",
+ "provider_protocol": "auto",
+ "provider_name": "openai",
+ "base_url": "https://gateway.example/v1",
+ "credential_key": "LLM_GATEWAY_API_KEY",
+ "tags": ["embedding"],
+ "priority": 1,
+ }
+ ]
assert "LLM_GATEWAY_EMBEDDING_MODEL" not in os.environ
From f8d1a7d516e3c349d5ec1d2ceefcff0c1cd77b32 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:30:28 +0900
Subject: [PATCH 149/393] chore(runtime): pin post-session embedding gateway
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 81ed6c8b2..d45d23432 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/5ef299de0d4736723157b3c1cb1dd4de12a4c125.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/a31e622885aac791673d5c8639f208f3cb19d737.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/5ef299de0d4736723157b3c1cb1dd4de12a4c125.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/a31e622885aac791673d5c8639f208f3cb19d737.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index df96558a1..e5757547b 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `5ef299de0d4736723157b3c1cb1dd4de12a4c125`. The pin remains explicit
+commit `a31e622885aac791673d5c8639f208f3cb19d737`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
From ccc9c27db4997dafa7f66a7bbe2436fabe9dbc17 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:32:20 +0900
Subject: [PATCH 150/393] fix: bind product evidence to source text
---
backend/app/post_content_worker.py | 8 ++++++--
lineageweave/operations_case_analysis.py | 1 +
tests/test_post_content_worker.py | 23 ++++++++++++++++++-----
3 files changed, 25 insertions(+), 7 deletions(-)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 8e88ff661..bbfa485b9 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -115,6 +115,7 @@ def can_see(row: asyncpg.Record) -> bool:
),
source_times[source.post_id][0],
source_times[source.post_id][1],
+ source.post_body,
)
for source in sources
)
@@ -198,7 +199,10 @@ async def _persist_product_analysis_if_needed(
pool, post_id, row, vision_client
)
sources = tuple(
- ProductEvidenceSource(source.post_id, source.text)
+ ProductEvidenceSource(
+ source.post_id,
+ source.source_text if source.source_text is not None else source.text,
+ )
for source in operation_sources
)
input_digest = product_analysis_input_sha256(sources)
@@ -517,7 +521,7 @@ async def process_post_content_job(
settings.orchestrator_api_key,
evidence_sources,
)
- except (HttpClientError, OSError, RuntimeError, TimeoutError, ValueError) as exc:
+ except (HttpClientError, OSError, RuntimeError, TimeoutError, TypeError, ValueError) as exc:
_logger.error("product evidence ingestion failed for post_id=%s", post_id)
record_server_failure(
"product_semantic_ingestion",
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py
index 184e70b92..b5e7b9fb4 100644
--- a/lineageweave/operations_case_analysis.py
+++ b/lineageweave/operations_case_analysis.py
@@ -114,6 +114,7 @@ class OperationsEvidenceSource:
text: str
observed_at: datetime | None = None
time_axis_code: str | None = None
+ source_text: str | None = None
@property
def input_sha256(self) -> str:
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index 17eba5d34..53ea00a5b 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -160,6 +160,7 @@ async def fetch(self, query: str, *_args: object):
assert sources[0].observed_at == observed_at
assert sources[0].time_axis_code == "event_occurred_at"
+ assert sources[0].source_text == "A claim was received."
def test_operations_sources_retry_when_a_source_clock_disappears(monkeypatch) -> None:
@@ -414,9 +415,17 @@ def test_product_analysis_persists_one_exact_authorized_window(monkeypatch) -> N
"""Product extraction reuses authorized sources and persists catalog outcomes."""
connection = _Connection(values=[False])
events: list[object] = []
+ submitted_sources: list[object] = []
async def evidence_sources(*_args, **_kwargs):
- return (OperationsEvidenceSource("post-1", "Synthetic", "Synthetic Product Q"),)
+ return (
+ OperationsEvidenceSource(
+ "post-1",
+ "Synthetic",
+ "Synthetic Product Q\nPersisted semantic evidence:\nproject: Product Alias",
+ source_text="Synthetic Product Q",
+ ),
+ )
async def resolve(_conn, mentions):
events.append(mentions)
@@ -432,9 +441,9 @@ async def persist(*args):
post_content_worker,
"ContextualOrchestratorProductExtractionClient",
lambda *_args: SimpleNamespace(
- extract=lambda sources: (
+ extract=lambda sources: submitted_sources.extend(sources) or (
post_content_worker.ProductEvidenceSource(sources[0].post_id, sources[0].text),
- )
+ ),
),
)
monkeypatch.setattr(post_content_worker, "resolve_product_mentions", resolve)
@@ -454,6 +463,7 @@ async def persist(*args):
)
assert len(events) == 2
assert len(events[1][3]) == 64
+ assert submitted_sources[0].text == "Synthetic Product Q"
def test_product_analysis_skips_same_digest(monkeypatch) -> None:
@@ -586,7 +596,10 @@ async def finish(_pool, _post_id, status, **_kwargs):
assert outcomes == [SUCCEEDED]
-def test_invalid_product_output_does_not_block_primary_post_evidence(monkeypatch) -> None:
+@pytest.mark.parametrize("error_type", [RuntimeError, TypeError])
+def test_invalid_product_output_does_not_block_primary_post_evidence(
+ monkeypatch, error_type
+) -> None:
"""Optional product extraction cannot discard structure, embedding, or cases."""
outcomes: list[str] = []
persisted: list[str] = []
@@ -596,7 +609,7 @@ async def claim(*_args, **_kwargs):
return _row(RUNNING, 1)
async def fail_product(*_args, **_kwargs):
- raise RuntimeError("synthetic non-verbatim product span")
+ raise error_type("synthetic malformed product response")
async def persist_cases(*_args, **_kwargs):
persisted.append("cases")
From e038410d3193837ae32ccf942e4d52c2850f834d Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:33:18 +0900
Subject: [PATCH 151/393] fix(orchestrator): preserve upstream embedding
discovery
---
docker/contextual-orchestrator/start.py | 16 +---------------
tests/test_contextual_orchestrator_start.py | 13 +------------
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 3 insertions(+), 28 deletions(-)
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 725f75540..3239ae616 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -48,7 +48,7 @@ def main() -> None:
raise SystemExit("LLM_GATEWAY_API_URL or LLM_GATEWAY_URL is required to start the gateway")
if not provider_url.rstrip("/").endswith("/v1"):
provider_url = provider_url.rstrip("/") + "/v1"
- embedding_model = os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", "").strip()
+ os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", None)
batch_registry_url = os.environ.pop("BATCH_JOB_REGISTRY_VALKEY_URL", "").strip()
raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
try:
@@ -70,19 +70,6 @@ def main() -> None:
agent["base_url"] = provider_url
agent["credential_key"] = "LLM_GATEWAY_API_KEY"
agent.setdefault("provider_protocol", "auto")
- if embedding_model:
- embedding_agent = {
- "id": "gateway_embedding_agent",
- "model": embedding_model,
- "provider_protocol": "auto",
- "base_url": provider_url,
- "credential_key": "LLM_GATEWAY_API_KEY",
- "tags": ["embedding"],
- "priority": 1,
- }
- if embedding_model == "text-embedding-3-large":
- embedding_agent["provider_name"] = "openai"
- agents["agents"].append(embedding_agent)
agents_path.write_text(json.dumps(agents), encoding="utf-8")
from contextual_orchestrator.credentials import register_credential
@@ -114,7 +101,6 @@ def main() -> None:
str(max_body_bytes),
]
del provider_url
- del embedding_model
del auth_token
from contextual_orchestrator.__main__ import main as serve
diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py
index bf63aca42..e90029ef4 100644
--- a/tests/test_contextual_orchestrator_start.py
+++ b/tests/test_contextual_orchestrator_start.py
@@ -141,16 +141,5 @@ def serve() -> None:
} & os.environ.keys()
agents = captured["agents"]
assert isinstance(agents, dict)
- assert [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])] == [
- {
- "id": "gateway_embedding_agent",
- "model": "text-embedding-3-large",
- "provider_protocol": "auto",
- "provider_name": "openai",
- "base_url": "https://gateway.example/v1",
- "credential_key": "LLM_GATEWAY_API_KEY",
- "tags": ["embedding"],
- "priority": 1,
- }
- ]
+ assert not [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])]
assert "LLM_GATEWAY_EMBEDDING_MODEL" not in os.environ
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index b5851a425..02b5b5ca3 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "5ef299de0d4736723157b3c1cb1dd4de12a4c125"
+ expected_embedding_contract_commit = "a31e622885aac791673d5c8639f208f3cb19d737"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 50f3bf8f294609987456d7e118ca9c70a7cfa204 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:34:19 +0900
Subject: [PATCH 152/393] test(security): account for product evidence SQL
audit
---
tests/test_static_sql_review_contracts.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/test_static_sql_review_contracts.py b/tests/test_static_sql_review_contracts.py
index d09df90d8..f991e2324 100644
--- a/tests/test_static_sql_review_contracts.py
+++ b/tests/test_static_sql_review_contracts.py
@@ -29,7 +29,7 @@
)
ASYNC_STATEMENT_METHODS = {"execute", "fetch", "fetchrow", "fetchval"}
SQL_REVIEW_RULE = "python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli"
-EXPECTED_SQL_SUPPRESSION_COUNT = 37
+EXPECTED_SQL_SUPPRESSION_COUNT = 38
@pytest.mark.parametrize("relative_path", SQL_REVIEW_PATHS)
From 88bfa26639faa3547fd97ad2dc3668db7381b99e Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:37:58 +0900
Subject: [PATCH 153/393] chore(runtime): pin per-input session gateway
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index d45d23432..9d900e727 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/a31e622885aac791673d5c8639f208f3cb19d737.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/74091e15a3bb19c4251d4f315860811cc9a11945.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/a31e622885aac791673d5c8639f208f3cb19d737.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/74091e15a3bb19c4251d4f315860811cc9a11945.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index e5757547b..d6df41ca1 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `a31e622885aac791673d5c8639f208f3cb19d737`. The pin remains explicit
+commit `74091e15a3bb19c4251d4f315860811cc9a11945`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index 02b5b5ca3..cf799404d 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "a31e622885aac791673d5c8639f208f3cb19d737"
+ expected_embedding_contract_commit = "74091e15a3bb19c4251d4f315860811cc9a11945"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 613a4613abfb4aab9682b57c3325e31915748c69 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:40:03 +0900
Subject: [PATCH 154/393] fix(orchestrator): restore explicit embedding
capability
---
docker/contextual-orchestrator/start.py | 16 +++++++++++++++-
tests/test_contextual_orchestrator_start.py | 13 ++++++++++++-
2 files changed, 27 insertions(+), 2 deletions(-)
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 3239ae616..725f75540 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -48,7 +48,7 @@ def main() -> None:
raise SystemExit("LLM_GATEWAY_API_URL or LLM_GATEWAY_URL is required to start the gateway")
if not provider_url.rstrip("/").endswith("/v1"):
provider_url = provider_url.rstrip("/") + "/v1"
- os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", None)
+ embedding_model = os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", "").strip()
batch_registry_url = os.environ.pop("BATCH_JOB_REGISTRY_VALKEY_URL", "").strip()
raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
try:
@@ -70,6 +70,19 @@ def main() -> None:
agent["base_url"] = provider_url
agent["credential_key"] = "LLM_GATEWAY_API_KEY"
agent.setdefault("provider_protocol", "auto")
+ if embedding_model:
+ embedding_agent = {
+ "id": "gateway_embedding_agent",
+ "model": embedding_model,
+ "provider_protocol": "auto",
+ "base_url": provider_url,
+ "credential_key": "LLM_GATEWAY_API_KEY",
+ "tags": ["embedding"],
+ "priority": 1,
+ }
+ if embedding_model == "text-embedding-3-large":
+ embedding_agent["provider_name"] = "openai"
+ agents["agents"].append(embedding_agent)
agents_path.write_text(json.dumps(agents), encoding="utf-8")
from contextual_orchestrator.credentials import register_credential
@@ -101,6 +114,7 @@ def main() -> None:
str(max_body_bytes),
]
del provider_url
+ del embedding_model
del auth_token
from contextual_orchestrator.__main__ import main as serve
diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py
index e90029ef4..bf63aca42 100644
--- a/tests/test_contextual_orchestrator_start.py
+++ b/tests/test_contextual_orchestrator_start.py
@@ -141,5 +141,16 @@ def serve() -> None:
} & os.environ.keys()
agents = captured["agents"]
assert isinstance(agents, dict)
- assert not [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])]
+ assert [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])] == [
+ {
+ "id": "gateway_embedding_agent",
+ "model": "text-embedding-3-large",
+ "provider_protocol": "auto",
+ "provider_name": "openai",
+ "base_url": "https://gateway.example/v1",
+ "credential_key": "LLM_GATEWAY_API_KEY",
+ "tags": ["embedding"],
+ "priority": 1,
+ }
+ ]
assert "LLM_GATEWAY_EMBEDDING_MODEL" not in os.environ
From a466ba61fb18e7eb14be6a7664dcf203957934a0 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:42:38 +0900
Subject: [PATCH 155/393] fix(orchestrator): keep model selection upstream
---
docker/contextual-orchestrator/start.py | 16 +---------------
tests/test_contextual_orchestrator_start.py | 13 +------------
2 files changed, 2 insertions(+), 27 deletions(-)
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 725f75540..3239ae616 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -48,7 +48,7 @@ def main() -> None:
raise SystemExit("LLM_GATEWAY_API_URL or LLM_GATEWAY_URL is required to start the gateway")
if not provider_url.rstrip("/").endswith("/v1"):
provider_url = provider_url.rstrip("/") + "/v1"
- embedding_model = os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", "").strip()
+ os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", None)
batch_registry_url = os.environ.pop("BATCH_JOB_REGISTRY_VALKEY_URL", "").strip()
raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
try:
@@ -70,19 +70,6 @@ def main() -> None:
agent["base_url"] = provider_url
agent["credential_key"] = "LLM_GATEWAY_API_KEY"
agent.setdefault("provider_protocol", "auto")
- if embedding_model:
- embedding_agent = {
- "id": "gateway_embedding_agent",
- "model": embedding_model,
- "provider_protocol": "auto",
- "base_url": provider_url,
- "credential_key": "LLM_GATEWAY_API_KEY",
- "tags": ["embedding"],
- "priority": 1,
- }
- if embedding_model == "text-embedding-3-large":
- embedding_agent["provider_name"] = "openai"
- agents["agents"].append(embedding_agent)
agents_path.write_text(json.dumps(agents), encoding="utf-8")
from contextual_orchestrator.credentials import register_credential
@@ -114,7 +101,6 @@ def main() -> None:
str(max_body_bytes),
]
del provider_url
- del embedding_model
del auth_token
from contextual_orchestrator.__main__ import main as serve
diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py
index bf63aca42..e90029ef4 100644
--- a/tests/test_contextual_orchestrator_start.py
+++ b/tests/test_contextual_orchestrator_start.py
@@ -141,16 +141,5 @@ def serve() -> None:
} & os.environ.keys()
agents = captured["agents"]
assert isinstance(agents, dict)
- assert [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])] == [
- {
- "id": "gateway_embedding_agent",
- "model": "text-embedding-3-large",
- "provider_protocol": "auto",
- "provider_name": "openai",
- "base_url": "https://gateway.example/v1",
- "credential_key": "LLM_GATEWAY_API_KEY",
- "tags": ["embedding"],
- "priority": 1,
- }
- ]
+ assert not [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])]
assert "LLM_GATEWAY_EMBEDDING_MODEL" not in os.environ
From 583059edcffe994b18a6fbf3cb3b00bf4647c2a3 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:44:49 +0900
Subject: [PATCH 156/393] fix: keep product evidence focal
---
backend/app/post_content_worker.py | 3 ++-
.../0228-evidence-bound-product-semantic-catalog.md | 5 ++++-
lineageweave/product_semantics.py | 7 ++++++-
tests/test_post_content_worker.py | 11 ++++++-----
tests/test_product_semantics.py | 12 ++++++++++++
5 files changed, 30 insertions(+), 8 deletions(-)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index bbfa485b9..559108c59 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -204,6 +204,7 @@ async def _persist_product_analysis_if_needed(
source.source_text if source.source_text is not None else source.text,
)
for source in operation_sources
+ if source.post_id == post_id
)
input_digest = product_analysis_input_sha256(sources)
async with pool.acquire() as conn:
@@ -521,7 +522,7 @@ async def process_post_content_job(
settings.orchestrator_api_key,
evidence_sources,
)
- except (HttpClientError, OSError, RuntimeError, TimeoutError, TypeError, ValueError) as exc:
+ except (HttpClientError, OSError, RuntimeError, TimeoutError, ValueError) as exc:
_logger.error("product evidence ingestion failed for post_id=%s", post_id)
record_server_failure(
"product_semantic_ingestion",
diff --git a/docs/adr/0228-evidence-bound-product-semantic-catalog.md b/docs/adr/0228-evidence-bound-product-semantic-catalog.md
index 5393fbba5..6d2aa37ab 100644
--- a/docs/adr/0228-evidence-bound-product-semantic-catalog.md
+++ b/docs/adr/0228-evidence-bound-product-semantic-catalog.md
@@ -50,7 +50,10 @@ flowchart LR
Historical processing reuses the durable post-content queue boundary, with a
bounded operator request and digest idempotency. HTTP requests never perform
-the extraction inline. Publication applies the existing authorization filter
+the extraction inline. Each post's product projection extracts only from that
+focal post's normalized source body; linked evidence remains available to
+operations inference but cannot make a sibling's product appear on the focal
+post. Publication applies the existing authorization filter
and source eligibility predicate to both the requested post and every evidence
post before returning the mention, relation, or evidence link. A visible post
cannot reveal a product span cited only by evidence the reader cannot access.
diff --git a/lineageweave/product_semantics.py b/lineageweave/product_semantics.py
index dcedbe2d9..4951066d4 100644
--- a/lineageweave/product_semantics.py
+++ b/lineageweave/product_semantics.py
@@ -159,7 +159,12 @@ def extract(
"x-request-timeout-ms": str(round(self._timeout * 1000)),
},
)
- content = chat_completion_content(response)
+ try:
+ content = chat_completion_content(response)
+ except TypeError as exc:
+ raise RuntimeError(
+ "contextual-orchestrator returned invalid product evidence"
+ ) from exc
parsed = parse_product_mentions(content, sources)
if parsed is None:
raise RuntimeError("contextual-orchestrator returned invalid product evidence")
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index 53ea00a5b..8b607b266 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -425,6 +425,9 @@ async def evidence_sources(*_args, **_kwargs):
"Synthetic Product Q\nPersisted semantic evidence:\nproject: Product Alias",
source_text="Synthetic Product Q",
),
+ OperationsEvidenceSource(
+ "post-2", "Sibling", "Sibling Product Z", source_text="Sibling Product Z"
+ ),
)
async def resolve(_conn, mentions):
@@ -464,6 +467,7 @@ async def persist(*args):
assert len(events) == 2
assert len(events[1][3]) == 64
assert submitted_sources[0].text == "Synthetic Product Q"
+ assert [source.post_id for source in submitted_sources] == ["post-1"]
def test_product_analysis_skips_same_digest(monkeypatch) -> None:
@@ -596,10 +600,7 @@ async def finish(_pool, _post_id, status, **_kwargs):
assert outcomes == [SUCCEEDED]
-@pytest.mark.parametrize("error_type", [RuntimeError, TypeError])
-def test_invalid_product_output_does_not_block_primary_post_evidence(
- monkeypatch, error_type
-) -> None:
+def test_invalid_product_output_does_not_block_primary_post_evidence(monkeypatch) -> None:
"""Optional product extraction cannot discard structure, embedding, or cases."""
outcomes: list[str] = []
persisted: list[str] = []
@@ -609,7 +610,7 @@ async def claim(*_args, **_kwargs):
return _row(RUNNING, 1)
async def fail_product(*_args, **_kwargs):
- raise error_type("synthetic malformed product response")
+ raise RuntimeError("synthetic malformed product response")
async def persist_cases(*_args, **_kwargs):
persisted.append("cases")
diff --git a/tests/test_product_semantics.py b/tests/test_product_semantics.py
index 03f5cb1a2..1072f9f21 100644
--- a/tests/test_product_semantics.py
+++ b/tests/test_product_semantics.py
@@ -113,3 +113,15 @@ def test_orchestrator_product_client_rejects_invalid_evidence(monkeypatch) -> No
ContextualOrchestratorProductExtractionClient("https://x", "secret").extract(
(ProductEvidenceSource("post-a", "Synthetic Model Q"),)
)
+
+
+def test_orchestrator_product_client_normalizes_malformed_envelope(monkeypatch) -> None:
+ """Malformed provider content is a bounded product-validation failure."""
+ monkeypatch.setattr(
+ "lineageweave.product_semantics.post_json",
+ lambda *args, **kwargs: {"choices": [{"message": {"content": None}}]},
+ )
+ with pytest.raises(RuntimeError, match="invalid product evidence"):
+ ContextualOrchestratorProductExtractionClient("https://x", "secret").extract(
+ (ProductEvidenceSource("post-a", "Synthetic Model Q"),)
+ )
From 1e861ea1e692fc7a644c82b679759968d0fe1970 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:50:35 +0900
Subject: [PATCH 157/393] fix(orchestrator): pin embedding auto-discovery
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 6 +++---
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 9d900e727..82f642c67 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/74091e15a3bb19c4251d4f315860811cc9a11945.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/7b4891ae7b82db1e5ed30e846dad91cf27e5c96b.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/74091e15a3bb19c4251d4f315860811cc9a11945.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/7b4891ae7b82db1e5ed30e846dad91cf27e5c96b.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index d6df41ca1..4923cbdd4 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `74091e15a3bb19c4251d4f315860811cc9a11945`. The pin remains explicit
+commit `7b4891ae7b82db1e5ed30e846dad91cf27e5c96b`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
@@ -33,8 +33,8 @@ The runtime contract is:
reconciliation prompt; independent VISION worker evidence is retained instead.
- A provider 4xx is reported as a failed orchestration attempt, never as a
successful empty semantic result.
-- An empty seed model is expanded from the configured gateway `/v1/models`
- endpoint; embedding-only rows are not added to the chat agent pool.
+- An empty seed model is expanded from configured provider discovery endpoints;
+ provider-declared embedding rows enter the embedding pool but never a chat role.
- A batch embedding request may omit `model`; contextual-orchestrator selects
an embedding-capable model and returns its identity for subsequent batches.
- A blank embedding input fails before provider selection; it is never sent as
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index cf799404d..237c11573 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "74091e15a3bb19c4251d4f315860811cc9a11945"
+ expected_embedding_contract_commit = "7b4891ae7b82db1e5ed30e846dad91cf27e5c96b"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 87b8b4d6514c4ad73d8a69e01d2969e6403e26cd Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:55:00 +0900
Subject: [PATCH 158/393] refactor(math): delete unused Python vector
arithmetic
---
ARCHITECTURE.md | 2 +-
CHANGELOG.md | 5 ++
docs/adr/0062-semantic-unit-embedding.md | 12 +--
...-externalize-local-mathematical-compute.md | 9 +-
...hon-mathematical-compute-boundary-audit.md | 9 +-
docs/lineage-bi-research-notes.md | 13 ++-
docs/product-technical-gap-baseline.md | 2 +-
lineageweave/chunking.py | 6 +-
lineageweave/embedding_client.py | 60 -------------
tests/test_embedding_client.py | 88 +------------------
tests/test_embedding_client_edges.py | 4 -
tests/test_math_boundary_inventory.py | 30 +++++++
tests/test_real_provider_integration.py | 50 +----------
13 files changed, 69 insertions(+), 221 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7d04f6daa..f02bf22a4 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -60,7 +60,7 @@ flowchart LR
| `models.py` | `Record`, `Edge`, `Tree` -- source-agnostic data shapes |
| `channels.py` | Independent `[0, 1]` scoring functions |
| `chunking.py` | Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order |
-| `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` |
+| `embedding_client.py` | Provider-neutral contextual-orchestrator embedding transport and strict vector-envelope validation; no local similarity arithmetic |
| `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) |
| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the buyer sees the picture, not the base64 string; GET does not call the vision client. |
| `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8c301f56..f1bef7750 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,11 @@ All notable changes to this project are documented here. Format follows
### Changed
+- Embedding transport now validates provider envelopes without retaining
+ production-unused Python cosine and chunk-max arithmetic. Active semantic
+ retrieval remains explicitly unavailable for migration until a versioned
+ Rust owner contract is accepted; no local or database substitute is added.
+
- Ask Agent now searches posts with the same persisted semantic project key as
part of its authorized evidence window before re-analysis. Missing evidence
remains a retry state rather than a request for the reader to attach an
diff --git a/docs/adr/0062-semantic-unit-embedding.md b/docs/adr/0062-semantic-unit-embedding.md
index 3763caed7..3cda12895 100644
--- a/docs/adr/0062-semantic-unit-embedding.md
+++ b/docs/adr/0062-semantic-unit-embedding.md
@@ -1,6 +1,6 @@
# ADR 0062: Embed paragraph and meaning-identifiable content units
-- Status: Accepted
+- Status: Accepted; arithmetic amended by ADR 0208
- Date: 2026-08-19
## Context
@@ -21,10 +21,7 @@ post whenever the source contains more than one unit:
- sentence boundaries when the caller explicitly selects the finer unit;
- conversation-turn boundaries for sender/receiver shaped content.
-`chunked_max_similarity` embeds every selected unit through the
-contextual-orchestrator embedding channel and max-pools unit-pair similarity.
-If a source produces zero or one unit, it falls back to one whole-text
-embedding because there is no meaningful pairwise chunk comparison. Persisted
+Persisted
`post_content_unit` rows are the provenance anchor for unit-level embeddings;
`post_content_embedding` and its value rows retain model and dimension
identity. The model identity is selected and returned by
@@ -35,6 +32,11 @@ provider-specific environment variable.
No local heuristic vector or whole-document replacement is allowed when the
configured embedding channel is unavailable.
+ADR 0208 removes the production-unused local pairwise cosine/max-pooling
+experiment. A future similarity score requires a versioned Rust owner envelope;
+LineageWeave retains semantic-unit selection, authorization, provenance, and
+strict envelope validation only.
+
## Consequences
- Ontology and semantic search can attribute a match to the specific content
diff --git a/docs/adr/0208-externalize-local-mathematical-compute.md b/docs/adr/0208-externalize-local-mathematical-compute.md
index 4305ffb53..d3cf076d8 100644
--- a/docs/adr/0208-externalize-local-mathematical-compute.md
+++ b/docs/adr/0208-externalize-local-mathematical-compute.md
@@ -2,7 +2,7 @@
**Decision status:** Accepted
**Date:** 2026-08-25
-**Amends:** ADR 0003, ADR 0024, ADR 0064, ADR 0084, ADR 0132, ADR 0145,
+**Amends:** ADR 0003, ADR 0024, ADR 0062, ADR 0064, ADR 0084, ADR 0132, ADR 0145,
ADR 0148, ADR 0167, ADR 0168, ADR 0182, ADR 0185, ADR 0200, ADR 0201, and
ADR 0205
@@ -89,6 +89,13 @@ different responsibility.
the reciprocal-rank contribution formula. RankWeave's Rust CPU/GPU migration
remains open, so this slice is owner-bound but not yet final execution-contract
compliance.
+- The production-unused `embedding_client.cosine_similarity` and
+ `chunked_max_similarity` experiments are deleted instead of being assigned
+ a new local implementation. Persisted semantic units remain the retrieval
+ provenance boundary from ADR 0062. Active Global Ask cosine stays named
+ migration debt until an accepted retrieval owner publishes a versioned Rust
+ scoring envelope; LineageWeave will validate and persist that envelope, not
+ reproduce its vector arithmetic.
## Stacked delivery order
diff --git a/docs/doctoring/python-mathematical-compute-boundary-audit.md b/docs/doctoring/python-mathematical-compute-boundary-audit.md
index 108e136b2..ed0a56d5e 100644
--- a/docs/doctoring/python-mathematical-compute-boundary-audit.md
+++ b/docs/doctoring/python-mathematical-compute-boundary-audit.md
@@ -8,8 +8,8 @@ does not relabel still-local Python paths as Rust/GPU compliant.
## Product-boundary sources read
-- LineageWeave `ARCHITECTURE.md` and accepted ADRs 0003, 0132, 0145,
- 0200, 0201, and 0205. This exact head has no standalone canonical PRD.
+- LineageWeave `docs/product-requirements.md`, `ARCHITECTURE.md`, and accepted
+ ADRs 0003, 0062, 0132, 0145, 0200, 0201, and 0205.
- TEPP `docs/product/prd-v0.4-approved.md`, whose approved TRSL-TM scope
owns temporal, relational, multilingual, topic, event, and trajectory
measurement.
@@ -25,7 +25,7 @@ does not relabel still-local Python paths as Rust/GPU compliant.
| `lineageweave/channel_weight_estimation.py` | dichotomization, synthetic simulation, MLS2PLM input construction, expected item information and normalization | fast-mlsirm, conditional on TEPP anchor | versioned anchored-weight artifact; strict digest/convergence validation | estimation scripts, seed/server/rebuild paths; `tests/test_channel_weight_estimation.py`, estimator-script tests |
| `lineageweave/period_report.py` | response matrix and owner-call orchestration remain; local category expectation and duplicate likelihood arithmetic removed | fast-mlsirm | `polytomous_expected_response`; diagnostics-owned held-out log likelihood; full period artifact remains debt | report ingestion and demo seed; period-report and report API tests |
| `lineageweave/leftover_pairs.py` | **migrated:** identifier projection and closest/farthest selection only | fast-mlsirm | protected-main Rust `residual_interaction_map` with residual, coverage, SVD/Gabriel coordinates, distances, reconstruction and shares | `period_report.py`, report ingestion/seed; owner contract and consumer projection tests |
-| `lineageweave/embedding_client.py` and `backend/app/post_chat_ingestion.py` | cosine similarity, vector norms, maximum semantic score | RankWeave retrieval-score contract | ranked evidence envelope over ABAC-visible semantic units | reconstruction text channel and Global Ask retrieval; embedding/post-chat tests |
+| `backend/app/post_chat_ingestion.py` | active Global Ask cosine, vector norm, maximum semantic score; the unused `embedding_client.py` cosine/max-pooling experiment is deleted | RankWeave or another accepted Rust retrieval-score owner | versioned ranked-evidence envelope over ABAC-visible semantic units; fail closed until accepted | Global Ask retrieval and post-chat tests |
| `lineageweave/knowledge_graph.py` | random walk with restart, convergence delta, adaptive relevance cutoff | RankWeave graph-ranking contract | ranked-node artifact with contribution and convergence evidence | related-person/entity API paths; knowledge-graph tests |
| `lineageweave/reconstruct.py` | channel-weight renormalization, candidate-score fusion and minimum-score decision | RankWeave fusion; TEPP supplies independent lineage criterion | accepted edge-ranking artifact; LineageWeave persists selected edge and channel provenance | lineage rebuild/start/seed/server; reconstruct, persistence, API tests |
| `lineageweave/rankweave_client.py` | channel construction and token overlap remain; **owner-bound:** classic/weighted RRF and contribution arithmetic now come from RankWeave #47, whose Python core still awaits the required Rust CPU/GPU migration | RankWeave | Rust-backed strict ranking artifact exposing owner-computed contributions and owned channel construction | `/api/rankings`, frontend Rankings; `tests/test_rankweave_client.py` and frontend tests |
@@ -38,6 +38,9 @@ import must be reviewed before LineageWeave's final wire-only state.
Validation-only uses of `math.isfinite` and database aggregation are not model
ownership and remain. Date ordering, counts, pagination, authorization, schema
validation, and presentation formatting also remain LineageWeave concerns.
+Exact JSON UTF-8 body length, server-advertised token/input ceilings, vector
+dimension equality, and finite-number checks in embedding backfill validate an
+owner envelope; they neither estimate token counts nor calculate similarity.
## Required owner contracts
diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md
index a0f202468..9f756450d 100644
--- a/docs/lineage-bi-research-notes.md
+++ b/docs/lineage-bi-research-notes.md
@@ -98,10 +98,9 @@ Embedding a whole flattened document as one vector dilutes a short
relevant unit with everything else in the same document -- the vector
averages over content that has nothing to do with the match being sought.
`lineageweave/chunking.py` splits a document into meaning-identifiable
-units first; `embedding_client.chunked_max_similarity` embeds every unit
-and takes the single highest-scoring pair, which is the standard
-passage-retrieval strategy for "a relevant unit is buried in a longer
-document." Four unit types, each grounded in a real boundary concept:
+units first. ADR 0208 removed the unused local Python cosine/max-pooling
+experiment; a versioned Rust retrieval-owner envelope must perform any future
+unit scoring. Four unit types remain, each grounded in a real boundary concept:
- **paragraph** -- subtopic-passage boundaries (Hearst, 1997, TextTiling).
- **sentence** -- the finer unit inside a paragraph.
@@ -114,10 +113,8 @@ document." Four unit types, each grounded in a real boundary concept:
**Honest scope note for this project's real dataset**: the real dataset
validated against in milestone 2 (43,814 short business records) has only
one real free-text field, and it is short (~28 characters average) with no
-paragraph, DOM, or conversation structure to chunk -- chunking a title
-does nothing useful and `chunked_max_similarity` degrades gracefully to
-plain whole-text embedding for exactly this case (a document that chunks
-to zero or one piece is embedded once, same as before chunking existed).
+paragraph, DOM, or conversation structure to chunk, so unit persistence does
+not imply or fabricate a local similarity score.
This module exists for when a richer content source is embedded --
concretely, the raw MHTML source artifacts this dataset's records were
derived from (tracked only as opaque content-addressed references in this
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index c61a43617..91f02a5f4 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -461,7 +461,7 @@ this file per §3.5 of the prior snapshot).
| SKOS organization aliases | Catalog binding and chip caption live on #480 / #482 | One catalog row per corroborated org; companion caption is hint-only until bound |
| Event Lineage evidence | Channel evidence and Allen relations live on #387 / #484 | Persist channel scores, explain them in the popup, never invent a fused score |
| Scientific measurement | Durable accepted TEPP receipts and LineageWeave #614's exact accepted snapshot/cutoff/run/pair-count consumer are protected; TEPP #237 remains open, so no registered producer artifact exists yet. #387 removes inferred/default persistence weights, but several older reconstruction tests still pass hand-authored numeric dictionaries that are not estimator evidence | Land TEPP #237 through its protected gate, then replace remaining reconstruction-test constants with provenance-bearing fast-mlsirm estimates over synthetic fixtures. Retain true-parameter RMSE recovery as the acceptance bar |
-| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stack also removes local RRF contribution arithmetic and corrects the invalid all-ones call to RankWeave's convex API through RankWeave #47 (`f92c3a2c`), but RankWeave #47 is still Python and therefore is not the final Rust CPU/GPU execution contract. The backend pins fast-mlsirm protected-main `09f762ded35786dd1078222a4577ff09d649816f`; TEPP-specific fast-mlsirm PR #1423 closed unmerged and is not a valid owner contract. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Define and land a domain-neutral pair-level criterion-observation contract in fast-mlsirm, with independent event-time provenance and CPU/GPU parity receipts; only then land the Rust estimator and delete the frozen Python channel-weight path. Continue the separate RankWeave and period-report owner migrations before requiring an empty transition inventory |
+| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stacked slice also deletes production-unused Python cosine/max-pooling helpers and freezes the one active direct-vector path with an AST inventory. Global Ask cosine remains Python migration debt because no accepted versioned Rust retrieval-scoring contract exists; #693 embedding backfill performs only exact request-envelope sizing, advertised-ceiling validation, vector-shape validation, and persistence, not token estimation or vector algebra. RankWeave #47 remains Python and is not the final Rust CPU/GPU execution contract. | Land a versioned Rust owner envelope for ABAC-visible semantic-unit scoring with model/version, input digest, deterministic CPU/GPU parity, finite dimensions, ranked evidence, and failure status. Then switch Global Ask to strict envelope validation, prove authorized semantic retrieval and zero-provider fail-closed behavior, and delete `KNOWN_LOCAL_DIRECT_VECTOR_ARITHMETIC`. Separately land the domain-neutral anchored-weight contract before deleting frozen channel-weight code. |
| Asynchronous authorization | Protected `main` rebuilds Global Ask worker scope after the bearer token leaves the request; #468 now persists exact Keyverse organization/process-unit scope in 3NF child tables and intersects it with current affiliations | Land #468 through the protected gate; prove a second affiliation and a revoked process unit cannot widen delayed-job evidence |
| Planned-facility intent | Planned-facility relationship intent remains only on closed, unmerged #490; earlier stack-only merges were not protected delivery | Recreate the evidence-backed slice on a current base and land through protected `main` before a release claim |
| Accessibility and responsive UX | #602 delivered base post-detail modal semantics; #605 adds selected-post refocus, collapsed/hidden/inert/CSS-invisible focus exclusion across both modal types, readable evidence separators, focused tests, and desktop/mobile Storybook screenshots | Land #605 through the protected gate, then complete screen-reader and authenticated Playwright acceptance on the exact release head |
diff --git a/lineageweave/chunking.py b/lineageweave/chunking.py
index edad0f59c..9d5bdb2b0 100644
--- a/lineageweave/chunking.py
+++ b/lineageweave/chunking.py
@@ -3,10 +3,8 @@
Embedding a whole flattened document as one vector buries a short relevant
passage under everything else in the same document -- the embedding
averages over content that has nothing to do with the query. Splitting
-into meaning-identifiable units first, embedding each unit, and comparing
-at the unit level (see :func:`chunked_max_similarity` in
-:mod:`lineageweave.embedding_client`) keeps a genuinely relevant unit's
-signal from being diluted by everything around it.
+into meaning-identifiable units first lets an external retrieval owner score
+an authorized, provenance-bearing unit instead of a flattened document.
Four unit types, each grounded in a boundary concept that already has a
name in the literature or a relevant standard rather than an arbitrary
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index 4e198028d..e2e5e0555 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -15,7 +15,6 @@
from collections.abc import Mapping
from typing import Protocol
-from .chunking import Chunk, chunk_by_paragraph
from .http_client import get_json, json_request_body, post_json
@@ -257,62 +256,3 @@ def orchestrator_embedding_client(base_url: str, api_key: str):
if not (base_url and api_key):
return NullEmbeddingClient()
return ContextualOrchestratorEmbeddingClient(base_url, api_key)
-
-
-def cosine_similarity(a: list[float], b: list[float]) -> float:
- """Cosine similarity mapped from ``[-1, 1]`` into the ``[0, 1]`` channel range."""
- dot = sum(x * y for x, y in zip(a, b))
- norm_a = math.sqrt(sum(x * x for x in a))
- norm_b = math.sqrt(sum(y * y for y in b))
- if norm_a == 0.0 or norm_b == 0.0:
- return 0.0
- cosine = dot / (norm_a * norm_b)
- return (cosine + 1.0) / 2.0
-
-
-def chunked_max_similarity(
- client: EmbeddingClient,
- text_a: str,
- text_b: str,
- *,
- chunker=chunk_by_paragraph,
-) -> tuple[float, Chunk, Chunk]:
- """Chunk both documents, embed every chunk, and return the single
- highest-scoring chunk pair.
-
- Embedding a whole document as one vector dilutes a short relevant unit
- with everything else in the same document. Max-pooling over chunk-pair
- similarity instead asks the right question for lineage matching: "is
- there ANY unit in A that plausibly matches ANY unit in B?" -- the
- standard passage-retrieval strategy for exactly this "relevant content
- is buried in a longer document" shape (see module docstring in
- ``chunking.py`` for the per-unit-type grounding).
-
- Falls back to whole-text embedding (a single implicit chunk) for any
- document that chunks to zero or one pieces, so short records (this
- project's real dataset's ``title_field``, ~28 characters on average)
- behave exactly as they did before chunking existed -- one embedding
- call each, same as :meth:`EmbeddingClient.embed`.
- """
- raw_chunks_a = chunker(text_a)
- raw_chunks_b = chunker(text_b)
- # Fallback applies for zero OR one chunk, not just zero: a single chunk
- # still means "nothing to max-pool over," and the chunker's own single
- # chunk may be normalized (e.g. paragraph-stripped) rather than the
- # original text, which would silently break the documented "behaves
- # exactly as it did before chunking existed" whole-text-embedding contract.
- chunks_a = raw_chunks_a if len(raw_chunks_a) > 1 else [Chunk(text=text_a, unit_type="whole", index=0)]
- chunks_b = raw_chunks_b if len(raw_chunks_b) > 1 else [Chunk(text=text_b, unit_type="whole", index=0)]
-
- vectors_a = [(chunk, client.embed(chunk.text)) for chunk in chunks_a]
- vectors_b = [(chunk, client.embed(chunk.text)) for chunk in chunks_b]
-
- best_score = 0.0
- best_pair: tuple[Chunk, Chunk] = (chunks_a[0], chunks_b[0])
- for chunk_a, vector_a in vectors_a:
- for chunk_b, vector_b in vectors_b:
- score = cosine_similarity(vector_a, vector_b)
- if score > best_score:
- best_score = score
- best_pair = (chunk_a, chunk_b)
- return best_score, best_pair[0], best_pair[1]
diff --git a/tests/test_embedding_client.py b/tests/test_embedding_client.py
index 1e8c23e2e..54ff12803 100644
--- a/tests/test_embedding_client.py
+++ b/tests/test_embedding_client.py
@@ -1,92 +1,8 @@
-"""Unit tests for embedding_client.chunked_max_similarity's whole-text
-fallback contract, using a fake (non-real-provider) client -- no network,
-no credentials needed. The real-provider test in
-tests/test_real_provider_integration.py proves the same function works
-against a live embedding endpoint; this file proves the fallback logic
-itself is correct regardless of provider.
-"""
+"""Unit tests for the contextual-orchestrator embedding transport."""
from __future__ import annotations
-from lineageweave.chunking import Chunk
-from lineageweave.embedding_client import (
- ContextualOrchestratorEmbeddingClient,
- chunked_max_similarity,
-)
-
-
-class _RecordingFakeEmbeddingClient:
- """Deterministic fake: embeds a string as a length-1 vector of its own
- length, so equal-length strings score identically and call counts are
- trivially inspectable.
- """
-
- available = True
-
- def __init__(self) -> None:
- self.embed_calls: list[str] = []
-
- def embed(self, text: str) -> list[float]:
- self.embed_calls.append(text)
- return [float(len(text))]
-
-
-def _chunk_to_two_pieces(text: str) -> list[Chunk]:
- half = len(text) // 2
- return [
- Chunk(text=text[:half], unit_type="paragraph", index=0),
- Chunk(text=text[half:], unit_type="paragraph", index=1),
- ]
-
-
-def _chunk_to_one_piece(text: str) -> list[Chunk]:
- # Deliberately NOT the identical string -- a real chunker normalizes
- # (e.g. strips/collapses whitespace), which is exactly the case the
- # fallback must override so the original text still gets embedded.
- return [Chunk(text=text.strip(), unit_type="paragraph", index=0)]
-
-
-def _chunk_to_zero_pieces(text: str) -> list[Chunk]:
- return []
-
-
-def test_falls_back_to_whole_text_when_chunker_returns_zero_pieces() -> None:
- client = _RecordingFakeEmbeddingClient()
- original = " padded text with whitespace "
-
- _, chunk_a, chunk_b = chunked_max_similarity(client, original, "other", chunker=_chunk_to_zero_pieces)
-
- assert chunk_a.unit_type == "whole"
- assert chunk_a.text == original # original whitespace preserved, not stripped
- assert client.embed_calls.count(original) == 1
-
-
-def test_falls_back_to_whole_text_when_chunker_returns_exactly_one_piece() -> None:
- client = _RecordingFakeEmbeddingClient()
- original = " padded text with whitespace "
-
- _, chunk_a, chunk_b = chunked_max_similarity(client, original, "other", chunker=_chunk_to_one_piece)
-
- assert chunk_a.unit_type == "whole"
- assert chunk_a.text == original # the chunker's stripped version must NOT be used
- assert client.embed_calls.count(original) == 1
- # Exactly one embedding call for this document -- the chunker's own
- # (normalized) chunk is never embedded once the fallback applies.
- assert client.embed_calls.count(original.strip()) == 0
-
-
-def test_uses_chunker_output_directly_when_it_returns_two_or_more_pieces() -> None:
- client = _RecordingFakeEmbeddingClient()
-
- _, chunk_a, chunk_b = chunked_max_similarity(
- client, "abcdefgh", "ijklmnop", chunker=_chunk_to_two_pieces
- )
-
- assert chunk_a.unit_type == "paragraph"
- assert chunk_b.unit_type == "paragraph"
- # Both documents chunk into 2 pieces each via _chunk_to_two_pieces --
- # the fallback must NOT engage, so every chunk gets its own embed call.
- assert len(client.embed_calls) == 4
+from lineageweave.embedding_client import ContextualOrchestratorEmbeddingClient
def test_orchestrator_embedding_client_submits_and_polls_batch(monkeypatch) -> None:
diff --git a/tests/test_embedding_client_edges.py b/tests/test_embedding_client_edges.py
index b509a2265..d0ace6d53 100644
--- a/tests/test_embedding_client_edges.py
+++ b/tests/test_embedding_client_edges.py
@@ -169,7 +169,3 @@ def embed(self, text: str) -> list[float]:
monkeypatch.setattr(embedding_client, "ContextualOrchestratorEmbeddingClient", Delegate)
client = embedding_client.OpenAiCompatibleEmbeddingClient("http://orchestrator", "key", "model")
assert client.embed("abc") == [3.0]
-
-
-def test_cosine_similarity_returns_zero_for_zero_vector() -> None:
- assert embedding_client.cosine_similarity([0.0], [1.0]) == 0.0
diff --git a/tests/test_math_boundary_inventory.py b/tests/test_math_boundary_inventory.py
index d4e07eb8c..9f805e32b 100644
--- a/tests/test_math_boundary_inventory.py
+++ b/tests/test_math_boundary_inventory.py
@@ -16,6 +16,7 @@
"lineageweave/rankweave_client.py",
"lineageweave/reconstruct.py",
}
+KNOWN_LOCAL_DIRECT_VECTOR_ARITHMETIC = {"backend/app/post_chat_ingestion.py"}
def _numerical_import_files() -> set[str]:
@@ -45,3 +46,32 @@ def test_no_new_local_numerical_owner_imports() -> None:
"""Require an ADR 0208 inventory update before local numerical scope grows."""
assert _numerical_import_files() == KNOWN_LOCAL_NUMERICAL_FILES
+
+
+def test_no_new_direct_python_vector_arithmetic() -> None:
+ """Freeze direct dot/norm arithmetic until a Rust owner contract replaces it."""
+
+ found: set[str] = set()
+ for base in (ROOT / "lineageweave", ROOT / "backend" / "app"):
+ for path in base.rglob("*.py"):
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ is_sqrt = (
+ isinstance(node.func, ast.Attribute)
+ and isinstance(node.func.value, ast.Name)
+ and node.func.value.id == "math"
+ and node.func.attr == "sqrt"
+ )
+ is_product_sum = (
+ isinstance(node.func, ast.Name)
+ and node.func.id == "sum"
+ and any(
+ isinstance(child, ast.BinOp) and isinstance(child.op, ast.Mult)
+ for child in ast.walk(node)
+ )
+ )
+ if is_sqrt or is_product_sum:
+ found.add(path.relative_to(ROOT).as_posix())
+ assert found == KNOWN_LOCAL_DIRECT_VECTOR_ARITHMETIC
diff --git a/tests/test_real_provider_integration.py b/tests/test_real_provider_integration.py
index b2a151688..cfbc505e8 100644
--- a/tests/test_real_provider_integration.py
+++ b/tests/test_real_provider_integration.py
@@ -16,11 +16,7 @@
import pytest
from lineageweave.adjudication_client import ContextualOrchestratorAdjudicationClient
-from lineageweave.embedding_client import (
- ContextualOrchestratorEmbeddingClient,
- chunked_max_similarity,
- cosine_similarity,
-)
+from lineageweave.embedding_client import ContextualOrchestratorEmbeddingClient
from lineageweave.fixtures import ambiguous_keyman_post
from lineageweave.image_content import orchestrator_vision_client
from lineageweave.keyman_extraction import (
@@ -42,57 +38,15 @@
reason="set LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL and LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY to run",
)
def test_contextual_orchestrator_embedding_client_returns_real_vectors() -> None:
- """A real embedding call, with a real, meaningful assertion: two labels
- about the same synthetic topic must cosine-score higher than two about
- unrelated synthetic topics -- not just "the call didn't crash".
- """
+ """A real embedding call returns a complete provider-owned vector."""
client = ContextualOrchestratorEmbeddingClient(
base_url=_ORCHESTRATOR_BASE_URL, api_key=_ORCHESTRATOR_API_KEY, model=_EMBEDDING_MODEL
)
a = client.embed("Quarterly budget review meeting notes")
- b = client.embed("Budget review follow-up: revised quarterly numbers")
- c = client.embed("Office parking lot repaving schedule")
-
- related_score = cosine_similarity(a, b)
- unrelated_score = cosine_similarity(a, c)
-
- assert 0.0 <= related_score <= 1.0
- assert 0.0 <= unrelated_score <= 1.0
- assert related_score > unrelated_score
assert len(a) > 8
-@pytest.mark.skipif(
- not (_ORCHESTRATOR_BASE_URL and _ORCHESTRATOR_API_KEY),
- reason="set LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL and LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY to run",
-)
-def test_chunked_embedding_finds_a_relevant_unit_buried_in_a_longer_document() -> None:
- """The real case chunking exists for: a short relevant passage sitting
- inside a much longer, mostly-irrelevant document. Whole-document
- embedding dilutes the relevant passage with everything around it;
- chunked max-pooled similarity should not.
- """
- client = ContextualOrchestratorEmbeddingClient(
- base_url=_ORCHESTRATOR_BASE_URL, api_key=_ORCHESTRATOR_API_KEY, model=_EMBEDDING_MODEL
- )
-
- query = "Quarterly budget review meeting notes"
- long_document = (
- "Office parking lot repaving schedule for the north campus.\n\n"
- "New badge access policy for the west entrance starting next month.\n\n"
- "Budget review follow-up: revised quarterly numbers and next steps.\n\n"
- "Cafeteria menu rotation for the coming season.\n\n"
- "Reminder about the annual fire drill scheduled for next week."
- )
-
- chunked_score, _best_a, best_b = chunked_max_similarity(client, query, long_document)
- whole_document_score = cosine_similarity(client.embed(query), client.embed(long_document))
-
- assert "Budget review" in best_b.text
- assert chunked_score > whole_document_score
-
-
@pytest.mark.skipif(
not (_ORCHESTRATOR_BASE_URL and _ORCHESTRATOR_API_KEY),
reason="set LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL and LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY to run",
From 776eee91e8401df15f86bd60a7448136d4e642c0 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:55:14 +0900
Subject: [PATCH 159/393] fix(embeddings): bound candidate packing scan
---
lineageweave/embedding_backfill.py | 22 ++++++++++++++--------
lineageweave/embedding_client.py | 3 ++-
tests/test_embedding_backfill.py | 9 +++++++++
tests/test_embedding_client_edges.py | 16 ++++++++++++++++
4 files changed, 41 insertions(+), 9 deletions(-)
diff --git a/lineageweave/embedding_backfill.py b/lineageweave/embedding_backfill.py
index 3eefdffcc..bfe27f55e 100644
--- a/lineageweave/embedding_backfill.py
+++ b/lineageweave/embedding_backfill.py
@@ -10,18 +10,13 @@
from .llm_context import build_post_llm_metadata
_SELECT_UNITS_SQL = """
-with candidates as (
+with bounded_candidates as materialized (
select unit.post_content_unit_id, unit.unit_text, unit.unit_index,
+ post.created_at as post_created_at,
post.post_id, post.author_account_id, post.source_process_unit_code,
post.source_author_code, post.source_company_code,
post.source_customer_code, post.source_project_code,
- post.source_sales_pool_code, entity.corporate_entity_code,
- row_number() over (
- order by post.created_at, post.post_id, unit.unit_index
- ) as candidate_ordinal,
- sum(octet_length(unit.unit_text) + 1) over (
- order by post.created_at, post.post_id, unit.unit_index
- ) as cumulative_text_bytes
+ post.source_sales_pool_code, entity.corporate_entity_code
from post_content_unit unit
join source_post post using (post_id)
left join corporate_entity entity using (corporate_entity_id)
@@ -30,6 +25,17 @@
select 1 from post_content_embedding existing
where existing.post_content_unit_id = unit.post_content_unit_id
)
+ order by post.created_at, post.post_id, unit.unit_index
+ limit $2
+), candidates as (
+ select bounded_candidates.*,
+ row_number() over (
+ order by post_created_at, post_id, unit_index
+ ) as candidate_ordinal,
+ sum(octet_length(unit_text) + 1) over (
+ order by post_created_at, post_id, unit_index
+ ) as cumulative_text_bytes
+ from bounded_candidates
)
select * from candidates
where candidate_ordinal = 1
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index 4e198028d..b27764191 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -187,7 +187,8 @@ def batch_request_body_size(
texts,
input_attributions=input_attributions,
input_metadata=input_metadata,
- )
+ ),
+ include_orchestrator_session=True,
)
)
diff --git a/tests/test_embedding_backfill.py b/tests/test_embedding_backfill.py
index 8a21c950b..451f2670d 100644
--- a/tests/test_embedding_backfill.py
+++ b/tests/test_embedding_backfill.py
@@ -188,3 +188,12 @@ def test_bulk_backfill_packs_largest_prefix_within_advertised_body_ceiling() ->
assert result["selected_units"] == 2
assert len(client.calls[0][0]) == 2
+
+
+def test_candidate_window_is_bounded_before_window_functions() -> None:
+ """Each batch ranks at most the operator-advertised input ceiling."""
+ bounded_start = _SELECT_UNITS_SQL.index("bounded_candidates as materialized")
+ limit_position = _SELECT_UNITS_SQL.index("limit $2")
+ window_position = _SELECT_UNITS_SQL.index("row_number() over")
+
+ assert bounded_start < limit_position < window_position
diff --git a/tests/test_embedding_client_edges.py b/tests/test_embedding_client_edges.py
index b509a2265..374c03333 100644
--- a/tests/test_embedding_client_edges.py
+++ b/tests/test_embedding_client_edges.py
@@ -3,6 +3,8 @@
import pytest
from lineageweave import embedding_client
+from lineageweave.http_client import json_request_body
+from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata
def test_missing_embedding_configuration_returns_null_client() -> None:
@@ -173,3 +175,17 @@ def embed(self, text: str) -> list[float]:
def test_cosine_similarity_returns_zero_for_zero_vector() -> None:
assert embedding_client.cosine_similarity([0.0], [1.0]) == 0.0
+
+
+def test_batch_body_size_matches_post_scoped_orchestrator_wire_body() -> None:
+ """The advertised ceiling includes the injected post session field."""
+ client = embedding_client.ContextualOrchestratorEmbeddingClient(
+ "http://orchestrator", "synthetic-key"
+ )
+ payload = client.batch_payload(["synthetic semantic unit"])
+ metadata = build_post_llm_metadata("synthetic-post", {})
+
+ with use_llm_metadata(metadata):
+ assert client.batch_request_body_size(["synthetic semantic unit"]) == len(
+ json_request_body(payload, include_orchestrator_session=True)
+ )
From 39ebe365ac24f7ed6e6de5d3d74ba6dfbfd71911 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 15:59:45 +0900
Subject: [PATCH 160/393] docs(adr): correct accelerator ownership boundary
---
...ative-mlx-mathematical-compute-boundary.md | 20 +++++++++++++------
docs/product-technical-gap-baseline.md | 2 +-
2 files changed, 15 insertions(+), 7 deletions(-)
diff --git a/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md b/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md
index e696aab83..89458c4bd 100644
--- a/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md
+++ b/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md
@@ -14,11 +14,15 @@ Xcode 15, and the macOS 14 SDK; its Linux distributions provide CPU or NVIDIA
CUDA backends instead. A Colima Linux VM therefore cannot truthfully issue an
MLX Metal execution receipt merely because its macOS host has an Apple GPU.
-ADR 0208 already assigns mathematical and psychometric arithmetic to the Rust
-cores of TEPP, fast-mlsirm, and RankWeave. Moving a formula into Python to gain
-access to MLX would violate that ownership boundary. ADR 0076's prohibition on
+ADR 0208 assigns psychometric and scientific numerical kernels to the Rust
+cores of TEPP and fast-mlsirm. RankWeave instead owns its current
+dependency-free Python retrieval-fusion, evaluation, and audit contract; that
+contract is neither a Rust kernel nor evidence for a future Rust vector-scoring
+owner. Moving an accepted TEPP or fast-mlsirm formula into Python to gain access
+to MLX would violate its ownership boundary. ADR 0076's prohibition on
LineageWeave-specific MLX model-provider routes remains unchanged: this ADR is
-about owner-repository numerical kernels, not LLM or VISION routing.
+about an already accepted owner-repository Rust kernel, not LLM, VISION,
+retrieval fusion, or an as-yet-unaccepted vector-scoring service.
## Decision
@@ -139,8 +143,12 @@ flowchart LR
- Compose stays portable. A machine without the native capability still runs
the product and honestly reports the affected measurement as unavailable or
uses a separately accepted owner CPU/CUDA result.
-- TEPP, fast-mlsirm, and RankWeave must each adopt this boundary in their own
- normative ADR before publishing an `mlx_metal` receipt.
+- TEPP and fast-mlsirm must each adopt this boundary in their own normative ADR
+ before publishing an `mlx_metal` receipt for an accepted Rust kernel.
+- RankWeave's current Python retrieval contract is unchanged by this ADR. Any
+ future Rust vector-scoring owner requires its own accepted ownership and wire
+ contract before this accelerator boundary can apply; this ADR does not assign
+ that responsibility or require RankWeave to adopt MLX.
## Alternatives considered
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 91f02a5f4..c432def51 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -25,7 +25,7 @@ containers while preserving named volumes.
| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending |
| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated synthetic runtime passed with the honest zero-result state |
| Project-specific journey | Explicit source/semantic project membership plus provenance-bearing TEPP TDT/CHRONOS predecessor, branch, and transition results | Candidate API preserves every explicit project membership, but its local event-time sort is only an observed-event list. It is no longer labeled as a Project Journey. Full journey delivery remains open until the accepted TEPP producer artifact is persisted and rendered; no fixed sales/order start or nearest-date edge is accepted. |
-| Apple-Silicon mathematical acceleration | ADR 0226 macOS-native Rust owner service with authenticated MLX Metal execution receipts | Normative boundary accepted; TEPP, fast-mlsirm, and RankWeave owner implementations and actual Metal parity receipts remain required before activation |
+| Apple-Silicon mathematical acceleration | ADR 0226 macOS-native Rust owner service with authenticated MLX Metal execution receipts | Normative boundary applies to accepted TEPP and fast-mlsirm Rust kernels; their owner implementations and actual Metal parity receipts remain required before activation. RankWeave's current dependency-free Python retrieval-fusion/evaluation contract is not Rust acceleration evidence and is not required to adopt MLX; a future Rust vector-scoring owner remains a separately accepted contract gap. |
| Ask answer citation-to-event navigation | ADR 0225; authorized cited source, observed source clock, focused evidence layer, and full-post navigation | Stacked candidate renders numbered answer citations and chronologically ordered evidence cards with bidirectional focus. Event and record clocks stay distinct; this list does not claim a Project Journey. Storybook and component interaction evidence are included; authenticated runtime screenshots remain required at the exact candidate head. |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback. Post-scoped evidence collection follows exact persisted `project_key` membership, and newly analyzed project evidence durably requeues completed sibling analyses that still have missing facts. Case-analysis reuse is bound to the exact ordered authorized evidence window and context, so the unchanged focal record re-analyzes when that window changes. Focused backend and replay-safe schema tests pass; authenticated exact-head runtime acceptance remains pending. |
From 4dc0f06611fb83074442af179ec86765c178521a Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:00:58 +0900
Subject: [PATCH 161/393] chore(runtime): pin hardened embedding gateway
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 82f642c67..80685c69d 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/7b4891ae7b82db1e5ed30e846dad91cf27e5c96b.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/c6a86bd8c0744e341c74ec4f4132030e72b5c362.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/7b4891ae7b82db1e5ed30e846dad91cf27e5c96b.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/c6a86bd8c0744e341c74ec4f4132030e72b5c362.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 4923cbdd4..db2be13ae 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `7b4891ae7b82db1e5ed30e846dad91cf27e5c96b`. The pin remains explicit
+commit `c6a86bd8c0744e341c74ec4f4132030e72b5c362`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index 237c11573..61c34ca6c 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "7b4891ae7b82db1e5ed30e846dad91cf27e5c96b"
+ expected_embedding_contract_commit = "c6a86bd8c0744e341c74ec4f4132030e72b5c362"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 12cfb5cb7da834c9b5e6959d201ae4c2bd941812 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:01:41 +0900
Subject: [PATCH 162/393] style(test): separate embedding transport cases
---
tests/test_embedding_client_edges.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tests/test_embedding_client_edges.py b/tests/test_embedding_client_edges.py
index a2800da72..97e90a9e1 100644
--- a/tests/test_embedding_client_edges.py
+++ b/tests/test_embedding_client_edges.py
@@ -171,6 +171,8 @@ def embed(self, text: str) -> list[float]:
monkeypatch.setattr(embedding_client, "ContextualOrchestratorEmbeddingClient", Delegate)
client = embedding_client.OpenAiCompatibleEmbeddingClient("http://orchestrator", "key", "model")
assert client.embed("abc") == [3.0]
+
+
def test_batch_body_size_matches_post_scoped_orchestrator_wire_body() -> None:
"""The advertised ceiling includes the injected post session field."""
client = embedding_client.ContextualOrchestratorEmbeddingClient(
From 48e6a999003a8c58aec49a7378eef7fa3f7f3aa7 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:05:02 +0900
Subject: [PATCH 163/393] fix(orchestrator): restore explicit embedding
capability
---
docker-compose.yml | 3 ++
docker/contextual-orchestrator/start.py | 16 +++++++-
.../0083-orchestrator-runtime-commit-pin.md | 6 +++
tests/test_contextual_orchestrator_start.py | 13 ++++++-
...orchestrator_compose_embedding_contract.py | 39 +++++++++++++++++++
5 files changed, 75 insertions(+), 2 deletions(-)
create mode 100644 tests/test_orchestrator_compose_embedding_contract.py
diff --git a/docker-compose.yml b/docker-compose.yml
index 0d8d247fe..2f44a856a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -120,6 +120,9 @@ services:
CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES: ${CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES:-8388608}
CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS: ${CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS:-host.docker.internal}
BATCH_JOB_REGISTRY_VALKEY_URL: redis://valkey:6379/1
+ # The gateway owns this provider/model binding. LineageWeave clients
+ # remain model-neutral and consume the advertised embedding capability.
+ LLM_GATEWAY_EMBEDDING_MODEL: text-embedding-3-large
OTEL_SERVICE_NAME: ${OTEL_ORCHESTRATOR_SERVICE_NAME:-contextual-orchestrator}
# Do not set OTEL_EXPORTER_OTLP_ENDPOINT here. An empty
# ${OTEL_EXPORTER_OTLP_ENDPOINT:-} interpolation would wipe a value from
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 3239ae616..725f75540 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -48,7 +48,7 @@ def main() -> None:
raise SystemExit("LLM_GATEWAY_API_URL or LLM_GATEWAY_URL is required to start the gateway")
if not provider_url.rstrip("/").endswith("/v1"):
provider_url = provider_url.rstrip("/") + "/v1"
- os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", None)
+ embedding_model = os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", "").strip()
batch_registry_url = os.environ.pop("BATCH_JOB_REGISTRY_VALKEY_URL", "").strip()
raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
try:
@@ -70,6 +70,19 @@ def main() -> None:
agent["base_url"] = provider_url
agent["credential_key"] = "LLM_GATEWAY_API_KEY"
agent.setdefault("provider_protocol", "auto")
+ if embedding_model:
+ embedding_agent = {
+ "id": "gateway_embedding_agent",
+ "model": embedding_model,
+ "provider_protocol": "auto",
+ "base_url": provider_url,
+ "credential_key": "LLM_GATEWAY_API_KEY",
+ "tags": ["embedding"],
+ "priority": 1,
+ }
+ if embedding_model == "text-embedding-3-large":
+ embedding_agent["provider_name"] = "openai"
+ agents["agents"].append(embedding_agent)
agents_path.write_text(json.dumps(agents), encoding="utf-8")
from contextual_orchestrator.credentials import register_credential
@@ -101,6 +114,7 @@ def main() -> None:
str(max_body_bytes),
]
del provider_url
+ del embedding_model
del auth_token
from contextual_orchestrator.__main__ import main as serve
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index db2be13ae..fc9ff2847 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -35,6 +35,12 @@ The runtime contract is:
successful empty semantic result.
- An empty seed model is expanded from configured provider discovery endpoints;
provider-declared embedding rows enter the embedding pool but never a chat role.
+- Because the configured gateway does not currently advertise a complete
+ embedding provider/model pair, canonical Compose supplies
+ `text-embedding-3-large` to the orchestrator bootstrap. The bootstrap owns
+ the exact OpenAI binding and publishes its official 2,048-input,
+ 8,192-token-per-input, and 300,000-total-token limits. LineageWeave clients
+ remain provider- and model-neutral and consume that advertised capability.
- A batch embedding request may omit `model`; contextual-orchestrator selects
an embedding-capable model and returns its identity for subsequent batches.
- A blank embedding input fails before provider selection; it is never sent as
diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py
index e90029ef4..bf63aca42 100644
--- a/tests/test_contextual_orchestrator_start.py
+++ b/tests/test_contextual_orchestrator_start.py
@@ -141,5 +141,16 @@ def serve() -> None:
} & os.environ.keys()
agents = captured["agents"]
assert isinstance(agents, dict)
- assert not [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])]
+ assert [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])] == [
+ {
+ "id": "gateway_embedding_agent",
+ "model": "text-embedding-3-large",
+ "provider_protocol": "auto",
+ "provider_name": "openai",
+ "base_url": "https://gateway.example/v1",
+ "credential_key": "LLM_GATEWAY_API_KEY",
+ "tags": ["embedding"],
+ "priority": 1,
+ }
+ ]
assert "LLM_GATEWAY_EMBEDDING_MODEL" not in os.environ
diff --git a/tests/test_orchestrator_compose_embedding_contract.py b/tests/test_orchestrator_compose_embedding_contract.py
new file mode 100644
index 000000000..341a52537
--- /dev/null
+++ b/tests/test_orchestrator_compose_embedding_contract.py
@@ -0,0 +1,39 @@
+"""Canonical Compose embedding capability contract tests."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+
+
+_ROOT = Path(__file__).parents[1]
+
+
+def test_rendered_compose_owns_exact_embedding_pair(tmp_path: Path) -> None:
+ """Render Compose and retain the gateway-owned exact embedding model."""
+ (tmp_path / ".env").write_text("", encoding="utf-8")
+ environment = os.environ.copy()
+ environment["HOME"] = str(tmp_path)
+ standalone_compose = shutil.which("docker-compose")
+ compose_command = [standalone_compose] if standalone_compose else ["docker", "compose"]
+ rendered = subprocess.run(
+ [*compose_command, "-f", str(_ROOT / "docker-compose.yml"), "config", "--format", "json"],
+ cwd=_ROOT,
+ env=environment,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ config = json.loads(rendered.stdout)
+ orchestrator_environment = config["services"]["orchestrator"]["environment"]
+
+ assert orchestrator_environment["LLM_GATEWAY_EMBEDDING_MODEL"] == "text-embedding-3-large"
+
+
+def test_lineage_clients_do_not_select_an_embedding_model() -> None:
+ """Keep provider/model ownership outside LineageWeave client services."""
+ compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
+ assert compose.count("LLM_GATEWAY_EMBEDDING_MODEL:") == 1
From 7fc0df589fbd9a321bee709d181b1508fcc92c01 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:05:02 +0900
Subject: [PATCH 164/393] fix(orchestrator): restore explicit embedding
capability
---
docker-compose.yml | 4 ++
docker/contextual-orchestrator/start.py | 25 ++++++++++-
.../0083-orchestrator-runtime-commit-pin.md | 7 ++++
tests/test_contextual_orchestrator_start.py | 25 ++++++++++-
...orchestrator_compose_embedding_contract.py | 41 +++++++++++++++++++
5 files changed, 100 insertions(+), 2 deletions(-)
create mode 100644 tests/test_orchestrator_compose_embedding_contract.py
diff --git a/docker-compose.yml b/docker-compose.yml
index 0d8d247fe..28363ba08 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -120,6 +120,10 @@ services:
CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES: ${CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES:-8388608}
CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS: ${CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS:-host.docker.internal}
BATCH_JOB_REGISTRY_VALKEY_URL: redis://valkey:6379/1
+ # The gateway owns this provider/model binding. LineageWeave clients
+ # remain model-neutral and consume the advertised embedding capability.
+ LLM_GATEWAY_EMBEDDING_MODEL: text-embedding-3-large
+ LLM_GATEWAY_EMBEDDING_PROVIDER: openai
OTEL_SERVICE_NAME: ${OTEL_ORCHESTRATOR_SERVICE_NAME:-contextual-orchestrator}
# Do not set OTEL_EXPORTER_OTLP_ENDPOINT here. An empty
# ${OTEL_EXPORTER_OTLP_ENDPOINT:-} interpolation would wipe a value from
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 3239ae616..7ce78979e 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -23,6 +23,22 @@ def _pop_first_env(*names: str) -> str:
return first
+def _embedding_agent(model: str, provider: str, provider_url: str) -> dict[str, object]:
+ """Build an operator-declared embedding agent without model-name inference."""
+ agent: dict[str, object] = {
+ "id": "gateway_embedding_agent",
+ "model": model,
+ "provider_protocol": "auto",
+ "base_url": provider_url,
+ "credential_key": "LLM_GATEWAY_API_KEY",
+ "tags": ["embedding"],
+ "priority": 1,
+ }
+ if provider:
+ agent["provider_name"] = provider
+ return agent
+
+
def main() -> None:
"""Register the provider credential and delegate to the upstream server."""
gateway_key = _pop_first_env("LLM_GATEWAY_API_KEY", "LLM_API_KEY")
@@ -48,7 +64,8 @@ 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"
- os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", None)
+ embedding_model = os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", "").strip()
+ embedding_provider = os.environ.pop("LLM_GATEWAY_EMBEDDING_PROVIDER", "").strip()
batch_registry_url = os.environ.pop("BATCH_JOB_REGISTRY_VALKEY_URL", "").strip()
raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
try:
@@ -70,6 +87,10 @@ def main() -> None:
agent["base_url"] = provider_url
agent["credential_key"] = "LLM_GATEWAY_API_KEY"
agent.setdefault("provider_protocol", "auto")
+ if embedding_model:
+ agents["agents"].append(
+ _embedding_agent(embedding_model, embedding_provider, provider_url)
+ )
agents_path.write_text(json.dumps(agents), encoding="utf-8")
from contextual_orchestrator.credentials import register_credential
@@ -101,6 +122,8 @@ def main() -> None:
str(max_body_bytes),
]
del provider_url
+ del embedding_model
+ del embedding_provider
del auth_token
from contextual_orchestrator.__main__ import main as serve
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index db2be13ae..71ca85abc 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -35,6 +35,13 @@ The runtime contract is:
successful empty semantic result.
- An empty seed model is expanded from configured provider discovery endpoints;
provider-declared embedding rows enter the embedding pool but never a chat role.
+- Because the configured gateway does not currently advertise a complete
+ embedding provider/model pair, canonical Compose supplies
+ the explicit `openai` / `text-embedding-3-large` pair to the orchestrator
+ bootstrap. The bootstrap does not infer a provider from the model name; it
+ publishes the pair's official 2,048-input,
+ 8,192-token-per-input, and 300,000-total-token limits. LineageWeave clients
+ remain provider- and model-neutral and consume that advertised capability.
- A batch embedding request may omit `model`; contextual-orchestrator selects
an embedding-capable model and returns its identity for subsequent batches.
- A blank embedding input fails before provider selection; it is never sent as
diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py
index e90029ef4..63834c080 100644
--- a/tests/test_contextual_orchestrator_start.py
+++ b/tests/test_contextual_orchestrator_start.py
@@ -55,6 +55,16 @@ 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_embedding_agent_does_not_infer_provider_from_model_name() -> None:
+ module = _load_start_module()
+
+ agent = module._embedding_agent(
+ "text-embedding-3-large", "", "https://gateway.example/v1"
+ )
+
+ assert "provider_name" not in agent
+
+
def test_provider_key_is_not_aliased_as_gateway_transport(monkeypatch) -> None:
module = _load_start_module()
for name in ("LLM_GATEWAY_API_KEY", "LLM_API_KEY"):
@@ -113,6 +123,7 @@ def serve() -> None:
monkeypatch.setenv("LLM_GATEWAY_API_URL", "https://gateway.example")
monkeypatch.setenv("BATCH_JOB_REGISTRY_VALKEY_URL", "redis://valkey:6379/1")
monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_MODEL", "text-embedding-3-large")
+ monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_PROVIDER", "openai")
module.main()
@@ -141,5 +152,17 @@ def serve() -> None:
} & os.environ.keys()
agents = captured["agents"]
assert isinstance(agents, dict)
- assert not [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])]
+ assert [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])] == [
+ {
+ "id": "gateway_embedding_agent",
+ "model": "text-embedding-3-large",
+ "provider_protocol": "auto",
+ "provider_name": "openai",
+ "base_url": "https://gateway.example/v1",
+ "credential_key": "LLM_GATEWAY_API_KEY",
+ "tags": ["embedding"],
+ "priority": 1,
+ }
+ ]
assert "LLM_GATEWAY_EMBEDDING_MODEL" not in os.environ
+ assert "LLM_GATEWAY_EMBEDDING_PROVIDER" not in os.environ
diff --git a/tests/test_orchestrator_compose_embedding_contract.py b/tests/test_orchestrator_compose_embedding_contract.py
new file mode 100644
index 000000000..5429b3f53
--- /dev/null
+++ b/tests/test_orchestrator_compose_embedding_contract.py
@@ -0,0 +1,41 @@
+"""Canonical Compose embedding capability contract tests."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+
+
+_ROOT = Path(__file__).parents[1]
+
+
+def test_rendered_compose_owns_exact_embedding_pair(tmp_path: Path) -> None:
+ """Render Compose and retain the gateway-owned exact embedding model."""
+ (tmp_path / ".env").write_text("", encoding="utf-8")
+ environment = os.environ.copy()
+ environment["HOME"] = str(tmp_path)
+ standalone_compose = shutil.which("docker-compose")
+ compose_command = [standalone_compose] if standalone_compose else ["docker", "compose"]
+ rendered = subprocess.run(
+ [*compose_command, "-f", str(_ROOT / "docker-compose.yml"), "config", "--format", "json"],
+ cwd=_ROOT,
+ env=environment,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ config = json.loads(rendered.stdout)
+ orchestrator_environment = config["services"]["orchestrator"]["environment"]
+
+ assert orchestrator_environment["LLM_GATEWAY_EMBEDDING_MODEL"] == "text-embedding-3-large"
+ assert orchestrator_environment["LLM_GATEWAY_EMBEDDING_PROVIDER"] == "openai"
+
+
+def test_lineage_clients_do_not_select_an_embedding_model() -> None:
+ """Keep provider/model ownership outside LineageWeave client services."""
+ compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
+ assert compose.count("LLM_GATEWAY_EMBEDDING_MODEL:") == 1
+ assert compose.count("LLM_GATEWAY_EMBEDDING_PROVIDER:") == 1
From 216685021556aa4c1faf087c494583e4a6a39dc8 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:16:24 +0900
Subject: [PATCH 165/393] docs(gap): record embedding persistence bottleneck
---
docs/product-technical-gap-baseline.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index c61a43617..a1c8bc57d 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -28,7 +28,7 @@ containers while preserving named volumes.
| Apple-Silicon mathematical acceleration | ADR 0226 macOS-native Rust owner service with authenticated MLX Metal execution receipts | Normative boundary accepted; TEPP, fast-mlsirm, and RankWeave owner implementations and actual Metal parity receipts remain required before activation |
| Ask answer citation-to-event navigation | ADR 0225; authorized cited source, observed source clock, focused evidence layer, and full-post navigation | Stacked candidate renders numbered answer citations and chronologically ordered evidence cards with bidirectional focus. Event and record clocks stay distinct; this list does not claim a Project Journey. Storybook and component interaction evidence are included; authenticated runtime screenshots remain required at the exact candidate head. |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
-| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback. Post-scoped evidence collection follows exact persisted `project_key` membership, and newly analyzed project evidence durably requeues completed sibling analyses that still have missing facts. Case-analysis reuse is bound to the exact ordered authorized evidence window and context, so the unchanged focal record re-analyzes when that window changes. Focused backend and replay-safe schema tests pass; authenticated exact-head runtime acceptance remains pending. |
+| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback. Post-scoped evidence collection follows exact persisted `project_key` membership, and newly analyzed project evidence durably requeues completed sibling analyses that still have missing facts. Case-analysis reuse is bound to the exact ordered authorized evidence window and context, so the unchanged focal record re-analyzes when that window changes. Canonical acceptance completed one provider-backed 2,048-input batch and atomically persisted 2,048 semantic-unit vectors (6,291,456 dimension values) with zero duplicate units and post-scoped session mismatch count 0. The provider step was durable before persistence, while normalized dimension insertion required about four minutes, peaked near one CPU of PostgreSQL, and showed no database wait event or OOM. The remaining product gap is a measured storage-throughput boundary: preserve atomic replacement and normalized auditability while reducing dimension-write CPU and operator-memory cost through a separately reviewed database contract; do not weaken WAL durability or expose partially replaced vectors. |
| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending |
| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet |
| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open at `063f10f3`; stacked #251–#254 provide fail-closed input validation, full joint precision, deterministic joint plausible-value draws, and the canonical research register, while complete provenance assembly remains gated. fast-mlsirm PR #1418 validates the Rust consumer envelope but intentionally returns `EstimatorUnavailable` until the scientific estimator lands. Runtime therefore remains honestly unavailable with no local Python or fallback score. |
From 03162a979b38678254a726269e51d6b467ddf1c3 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:18:48 +0900
Subject: [PATCH 166/393] fix(worker): report durable consumer progress
---
backend/app/worker.py | 2 ++
backend/app/worker_health.py | 52 +++++++++++++++++++++++++++++
docker-compose.yml | 2 +-
scripts/backfill_post_embeddings.py | 4 +++
tests/test_worker_health.py | 46 +++++++++++++++++++++++++
5 files changed, 105 insertions(+), 1 deletion(-)
create mode 100644 backend/app/worker_health.py
create mode 100644 tests/test_worker_health.py
diff --git a/backend/app/worker.py b/backend/app/worker.py
index d627f492e..fb9662f26 100644
--- a/backend/app/worker.py
+++ b/backend/app/worker.py
@@ -18,6 +18,7 @@
_vision_client,
)
from backend.app.post_content_worker import run_post_content_worker
+from backend.app.worker_health import run_worker_heartbeat
from lineageweave.observability import configure_telemetry, shutdown_telemetry
@@ -28,6 +29,7 @@ async def run_worker_process() -> None:
pool = await create_pool(settings.database_url)
valkey = create_valkey_client(settings.valkey_url)
workers = (
+ asyncio.create_task(run_worker_heartbeat()),
asyncio.create_task(
run_analysis_run_worker(
valkey,
diff --git a/backend/app/worker_health.py b/backend/app/worker_health.py
new file mode 100644
index 000000000..664f2c4e9
--- /dev/null
+++ b/backend/app/worker_health.py
@@ -0,0 +1,52 @@
+"""Progress-based health contract for the durable worker event loop."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+import time
+
+
+HEARTBEAT_PATH = Path("/tmp/lineageweave-worker-heartbeat")
+HEALTHCHECK_STATE_PATH = Path("/tmp/lineageweave-worker-healthcheck-state")
+
+
+def record_worker_heartbeat(path: Path = HEARTBEAT_PATH) -> None:
+ """Record one monotonic event-loop progress sample atomically."""
+ temporary = path.with_suffix(".tmp")
+ temporary.write_text(str(time.monotonic_ns()), encoding="ascii")
+ temporary.replace(path)
+
+
+async def run_worker_heartbeat(path: Path = HEARTBEAT_PATH) -> None:
+ """Record progress once per broker-poll interval until cancelled."""
+ while True:
+ record_worker_heartbeat(path)
+ await asyncio.sleep(1.0)
+
+
+def heartbeat_has_advanced(
+ heartbeat_path: Path = HEARTBEAT_PATH,
+ state_path: Path = HEALTHCHECK_STATE_PATH,
+) -> bool:
+ """Return whether the heartbeat advanced since the prior health probe."""
+ try:
+ current = int(heartbeat_path.read_text(encoding="ascii"))
+ except (FileNotFoundError, ValueError):
+ return False
+ previous: int | None = None
+ try:
+ previous = int(state_path.read_text(encoding="ascii"))
+ except (FileNotFoundError, ValueError):
+ pass
+ state_path.write_text(str(current), encoding="ascii")
+ return current >= 0 and (previous is None or current > previous)
+
+
+def main() -> None:
+ """Exit successfully only when the durable worker event loop progressed."""
+ raise SystemExit(0 if heartbeat_has_advanced() else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docker-compose.yml b/docker-compose.yml
index 28363ba08..c1a2c60b4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -228,7 +228,7 @@ services:
searxng:
condition: service_healthy
healthcheck:
- test: ["CMD-SHELL", "kill -0 1"]
+ test: ["CMD", "python", "-m", "backend.app.worker_health"]
interval: 10s
timeout: 3s
retries: 3
diff --git a/scripts/backfill_post_embeddings.py b/scripts/backfill_post_embeddings.py
index a26677053..03490e913 100755
--- a/scripts/backfill_post_embeddings.py
+++ b/scripts/backfill_post_embeddings.py
@@ -40,6 +40,10 @@ async def _run(target_dsn: str) -> dict[str, int | str]:
if not client.available:
raise RuntimeError("embedding is unavailable; configure contextual-orchestrator")
capabilities = client.batch_capabilities()
+ # LineageWeave bounds only the provider-neutral HTTP envelope. The
+ # advertised token/character ceilings are enforced by the orchestrator's
+ # Rust token-boundary splitter and durable shard runner; reproducing that
+ # arithmetic here would create a divergent model/provider policy boundary.
conn = await asyncpg.connect(target_dsn)
try:
return await backfill_post_content_embeddings(
diff --git a/tests/test_worker_health.py b/tests/test_worker_health.py
new file mode 100644
index 000000000..e3f0831f2
--- /dev/null
+++ b/tests/test_worker_health.py
@@ -0,0 +1,46 @@
+"""Tests for progress-based durable-worker health reporting."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+
+import pytest
+
+from backend.app import worker_health
+
+
+def test_health_requires_progress_between_probes(tmp_path: Path) -> None:
+ """A live PID with an unchanged event-loop heartbeat is unhealthy."""
+ heartbeat = tmp_path / "heartbeat"
+ state = tmp_path / "state"
+
+ assert worker_health.heartbeat_has_advanced(heartbeat, state) is False
+ heartbeat.write_text("1", encoding="ascii")
+ assert worker_health.heartbeat_has_advanced(heartbeat, state) is True
+ assert worker_health.heartbeat_has_advanced(heartbeat, state) is False
+ heartbeat.write_text("2", encoding="ascii")
+ assert worker_health.heartbeat_has_advanced(heartbeat, state) is True
+
+
+def test_malformed_heartbeat_fails_closed(tmp_path: Path) -> None:
+ """Malformed progress evidence is never reported as healthy."""
+ heartbeat = tmp_path / "heartbeat"
+ heartbeat.write_text("not-a-counter", encoding="ascii")
+
+ assert worker_health.heartbeat_has_advanced(heartbeat, tmp_path / "state") is False
+
+
+def test_heartbeat_records_before_first_sleep(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Startup publishes progress before the first broker-poll interval."""
+ heartbeat = tmp_path / "heartbeat"
+
+ async def cancel_after_first_record(_seconds: float) -> None:
+ raise asyncio.CancelledError
+
+ monkeypatch.setattr(worker_health.asyncio, "sleep", cancel_after_first_record)
+ with pytest.raises(asyncio.CancelledError):
+ asyncio.run(worker_health.run_worker_heartbeat(heartbeat))
+ assert int(heartbeat.read_text(encoding="ascii")) >= 0
From 2ee443e6b2dca27a3a1a25d900b659bf6cfcba64 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:21:05 +0900
Subject: [PATCH 167/393] chore(runtime): pin reviewed embedding failover
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 80685c69d..56b9d918d 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/c6a86bd8c0744e341c74ec4f4132030e72b5c362.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1cf837d8c6cd13cc66041f6da3c64500c474601e.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/c6a86bd8c0744e341c74ec4f4132030e72b5c362.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1cf837d8c6cd13cc66041f6da3c64500c474601e.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 71ca85abc..2da86481d 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `c6a86bd8c0744e341c74ec4f4132030e72b5c362`. The pin remains explicit
+commit `1cf837d8c6cd13cc66041f6da3c64500c474601e`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index 61c34ca6c..8c5ba3379 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "c6a86bd8c0744e341c74ec4f4132030e72b5c362"
+ expected_embedding_contract_commit = "1cf837d8c6cd13cc66041f6da3c64500c474601e"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From a3cd34c4074dd7fddc72fcb7defa7c062ab21ac2 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:22:59 +0900
Subject: [PATCH 168/393] fix(queue): requeue missing operations evidence
---
backend/app/post_content_queue.py | 5 +++++
docs/adr/0098-valkey-backed-post-content-ingestion.md | 5 +++++
docs/product-technical-gap-baseline.md | 2 +-
tests/test_post_content_queue.py | 2 ++
4 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py
index 2cc7b361d..d01074e1f 100644
--- a/backend/app/post_content_queue.py
+++ b/backend/app/post_content_queue.py
@@ -370,6 +370,11 @@ async def enqueue_post_content_backfill(
or structure.decision_source_code = 'unresolved'
)
))
+ or ($3::boolean and not exists (
+ select 1
+ from operations_case_analysis analysis
+ where analysis.post_id = post.post_id
+ ))
)
order by post.created_at, post.post_id
limit $4
diff --git a/docs/adr/0098-valkey-backed-post-content-ingestion.md b/docs/adr/0098-valkey-backed-post-content-ingestion.md
index b106cb287..c5098e9ea 100644
--- a/docs/adr/0098-valkey-backed-post-content-ingestion.md
+++ b/docs/adr/0098-valkey-backed-post-content-ingestion.md
@@ -101,6 +101,11 @@ The producer applies `SOURCE_POST_ELIGIBILITY_SQL`, locks source rows with
`SKIP LOCKED`, selects only new or incomplete-succeeded jobs, rechecks the
shared completeness predicate, and records the existing job state in
PostgreSQL. Repeated calls therefore do not reset active or terminal work.
+When contextual-orchestrator evidence is required, an otherwise complete
+successful job with no `operations_case_analysis` row is also incomplete and
+eligible for the same bounded requeue. This lets records completed before the
+operations extractor was deployed enter that extractor without a synchronous
+provider call or a second queue.
If Valkey is unavailable, the response reports `recovery_pending` and the
committed queued rows are republished by the existing recovery sweep. Direct
provider calls are not a substitute for the worker queue.
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index a1c8bc57d..f0a774607 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -447,7 +447,7 @@ this file per §3.5 of the prior snapshot).
| Gap | Current evidence | Acceptance requirement |
| --- | --- | --- |
| Protected release | 11 open PRs at the 07:26 KST snapshot; the exact-head inventory in section 1 records their current evidence boundaries | Terminal exact-head checks, no unresolved threads, independent exact-head approvals, protected squash-merge SHA |
-| Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. The current Dashboard stack adds a candidate `post_admin`-gated, 1--200-row durable semantic-backfill enqueue path that reuses PostgreSQL recovery and never runs providers in HTTP; authorized-corpus acceptance remains unavailable | Land the candidate, then perform authenticated authorized-corpus acceptance with aggregate queued/published/recovery and derived-evidence counts while retaining fail-closed no-match behavior |
+| Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. The current Dashboard stack adds a candidate `post_admin`-gated, 1--200-row durable semantic-backfill enqueue path that reuses PostgreSQL recovery, includes successful records completed before operations extraction, and never runs providers in HTTP; authorized-corpus acceptance remains unavailable | Land the candidate, then perform authenticated authorized-corpus acceptance with aggregate queued/published/recovery and derived-evidence counts while retaining fail-closed no-match behavior |
| Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push |
| Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc |
| Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence |
diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py
index 2c820cbfb..5f019b548 100644
--- a/tests/test_post_content_queue.py
+++ b/tests/test_post_content_queue.py
@@ -62,6 +62,8 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, str]]:
assert "source_draft_code" in query
assert "source_deleted_flag" in query
assert "job.post_id is null or job.status_code = $1" in query
+ assert "from operations_case_analysis analysis" in query
+ assert "analysis.post_id = post.post_id" in query
assert "for update of post skip locked" in query.lower()
assert args == (SUCCEEDED, True, True, 2)
return [
From a31e56ead1d1b0bb187262a4af891948ac49e080 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:26:53 +0900
Subject: [PATCH 169/393] test(compose): bind gateway auth contract
---
tests/test_orchestrator_compose_embedding_contract.py | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/tests/test_orchestrator_compose_embedding_contract.py b/tests/test_orchestrator_compose_embedding_contract.py
index 5429b3f53..213e8a52d 100644
--- a/tests/test_orchestrator_compose_embedding_contract.py
+++ b/tests/test_orchestrator_compose_embedding_contract.py
@@ -29,9 +29,17 @@ def test_rendered_compose_owns_exact_embedding_pair(tmp_path: Path) -> None:
)
config = json.loads(rendered.stdout)
orchestrator_environment = config["services"]["orchestrator"]["environment"]
+ backend_environment = config["services"]["backend"]["environment"]
assert orchestrator_environment["LLM_GATEWAY_EMBEDDING_MODEL"] == "text-embedding-3-large"
assert orchestrator_environment["LLM_GATEWAY_EMBEDDING_PROVIDER"] == "openai"
+ assert (
+ orchestrator_environment["CONTEXTUAL_ORCHESTRATOR_TOKEN"]
+ == backend_environment["ORCHESTRATOR_API_KEY"]
+ )
+ assert config["services"]["orchestrator"]["healthcheck"]["test"][-1].find(
+ "/healthz"
+ ) >= 0
def test_lineage_clients_do_not_select_an_embedding_model() -> None:
From 33f5543264ea38481859f4d40071dd1b231c5537 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:33:31 +0900
Subject: [PATCH 170/393] fix(queue): wake complete posts missing case analysis
---
backend/app/post_content_queue.py | 12 ++++-
tests/test_post_content_queue.py | 73 +++++++++++++++++++++++++++++++
2 files changed, 84 insertions(+), 1 deletion(-)
diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py
index d01074e1f..e01aad3ac 100644
--- a/backend/app/post_content_queue.py
+++ b/backend/app/post_content_queue.py
@@ -393,16 +393,26 @@ async def enqueue_post_content_backfill(
)
for row in rows:
post_id = str(row["post_id"])
+ body = str(row["post_body"] or "")
complete = await post_content_is_complete(
conn,
post_id,
require_embedding=require_embedding,
require_structure=require_structure,
)
+ if complete and require_structure:
+ complete = bool(
+ await conn.fetchval(
+ "select exists (select 1 from operations_case_analysis "
+ "where post_id = $1 and source_body_sha256 = $2)",
+ post_id,
+ source_body_sha256(body),
+ )
+ )
request = await ensure_post_content_job(
conn,
post_id,
- str(row["post_body"] or ""),
+ body,
content_complete=complete,
)
if request.should_publish:
diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py
index 5f019b548..5a02b3b63 100644
--- a/tests/test_post_content_queue.py
+++ b/tests/test_post_content_queue.py
@@ -175,6 +175,79 @@ async def ensure(
}
+def test_backfill_requeues_complete_content_missing_operations_analysis(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A pre-extractor success is incomplete until its exact body is analyzed."""
+
+ class Transaction:
+ async def __aenter__(self) -> None:
+ return None
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ class Connection:
+ def transaction(self) -> Transaction:
+ return Transaction()
+
+ async def fetch(self, _query: str, *_args: object) -> list[dict[str, str]]:
+ return [
+ {
+ "post_id": "00000000-0000-0000-0000-000000000001",
+ "post_body": "historical success",
+ }
+ ]
+
+ async def fetchval(self, query: str, *args: object) -> bool:
+ assert "operations_case_analysis" in query
+ assert args == (
+ "00000000-0000-0000-0000-000000000001",
+ source_body_sha256("historical success"),
+ )
+ return False
+
+ class Acquire:
+ async def __aenter__(self) -> Connection:
+ return Connection()
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ class Pool:
+ def acquire(self) -> Acquire:
+ return Acquire()
+
+ async def content_complete(*_args: object, **_kwargs: object) -> bool:
+ return True
+
+ async def ensure(
+ _conn: object, post_id: str, body: str, *, content_complete: bool
+ ) -> PostContentJobRequest:
+ assert content_complete is False
+ return PostContentJobRequest(post_id, source_body_sha256(body), QUEUED, True)
+
+ async def publish(*_args: object, **_kwargs: object) -> str:
+ return "1-0"
+
+ from backend.app import post_content_queue
+
+ monkeypatch.setattr(post_content_queue, "post_content_is_complete", content_complete)
+ monkeypatch.setattr(post_content_queue, "ensure_post_content_job", ensure)
+ monkeypatch.setattr(post_content_queue, "publish_post_content_event", publish)
+ result = asyncio.run(
+ enqueue_post_content_backfill(
+ Pool(), object(), limit=1, require_embedding=True, require_structure=True
+ )
+ )
+ assert result == {
+ "selected_posts": 1,
+ "queued_posts": 1,
+ "published_events": 1,
+ "recovery_pending": 0,
+ }
+
+
@pytest.mark.parametrize("limit", [0, 201])
def test_backfill_rejects_unbounded_pages(limit: int) -> None:
"""The shared producer rejects callers that bypass the HTTP model bound."""
From f07cef0e9808e6d78c57a62f409524fb90afdb46 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:37:24 +0900
Subject: [PATCH 171/393] fix(orchestrator): preserve upstream model ownership
---
docker-compose.yml | 4 ---
docker/contextual-orchestrator/Dockerfile | 4 +--
docker/contextual-orchestrator/start.py | 24 ---------------
.../0083-orchestrator-runtime-commit-pin.md | 11 ++-----
tests/test_contextual_orchestrator_start.py | 30 ++-----------------
tests/test_documentation_hygiene.py | 2 +-
...orchestrator_compose_embedding_contract.py | 12 ++++----
7 files changed, 15 insertions(+), 72 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index c1a2c60b4..4faee915f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -120,10 +120,6 @@ services:
CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES: ${CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES:-8388608}
CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS: ${CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS:-host.docker.internal}
BATCH_JOB_REGISTRY_VALKEY_URL: redis://valkey:6379/1
- # The gateway owns this provider/model binding. LineageWeave clients
- # remain model-neutral and consume the advertised embedding capability.
- LLM_GATEWAY_EMBEDDING_MODEL: text-embedding-3-large
- LLM_GATEWAY_EMBEDDING_PROVIDER: openai
OTEL_SERVICE_NAME: ${OTEL_ORCHESTRATOR_SERVICE_NAME:-contextual-orchestrator}
# Do not set OTEL_EXPORTER_OTLP_ENDPOINT here. An empty
# ${OTEL_EXPORTER_OTLP_ENDPOINT:-} interpolation would wipe a value from
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 56b9d918d..49c01a287 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1cf837d8c6cd13cc66041f6da3c64500c474601e.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/4212b25db9948c14f366124d92687383b3a0c712.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1cf837d8c6cd13cc66041f6da3c64500c474601e.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/4212b25db9948c14f366124d92687383b3a0c712.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 7ce78979e..5cf29e136 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -23,22 +23,6 @@ def _pop_first_env(*names: str) -> str:
return first
-def _embedding_agent(model: str, provider: str, provider_url: str) -> dict[str, object]:
- """Build an operator-declared embedding agent without model-name inference."""
- agent: dict[str, object] = {
- "id": "gateway_embedding_agent",
- "model": model,
- "provider_protocol": "auto",
- "base_url": provider_url,
- "credential_key": "LLM_GATEWAY_API_KEY",
- "tags": ["embedding"],
- "priority": 1,
- }
- if provider:
- agent["provider_name"] = provider
- return agent
-
-
def main() -> None:
"""Register the provider credential and delegate to the upstream server."""
gateway_key = _pop_first_env("LLM_GATEWAY_API_KEY", "LLM_API_KEY")
@@ -64,8 +48,6 @@ 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"
- embedding_model = os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", "").strip()
- embedding_provider = os.environ.pop("LLM_GATEWAY_EMBEDDING_PROVIDER", "").strip()
batch_registry_url = os.environ.pop("BATCH_JOB_REGISTRY_VALKEY_URL", "").strip()
raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
try:
@@ -87,10 +69,6 @@ def main() -> None:
agent["base_url"] = provider_url
agent["credential_key"] = "LLM_GATEWAY_API_KEY"
agent.setdefault("provider_protocol", "auto")
- if embedding_model:
- agents["agents"].append(
- _embedding_agent(embedding_model, embedding_provider, provider_url)
- )
agents_path.write_text(json.dumps(agents), encoding="utf-8")
from contextual_orchestrator.credentials import register_credential
@@ -122,8 +100,6 @@ def main() -> None:
str(max_body_bytes),
]
del provider_url
- del embedding_model
- del embedding_provider
del auth_token
from contextual_orchestrator.__main__ import main as serve
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 2da86481d..6969b78c1 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `1cf837d8c6cd13cc66041f6da3c64500c474601e`. The pin remains explicit
+commit `4212b25db9948c14f366124d92687383b3a0c712`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
@@ -35,13 +35,8 @@ The runtime contract is:
successful empty semantic result.
- An empty seed model is expanded from configured provider discovery endpoints;
provider-declared embedding rows enter the embedding pool but never a chat role.
-- Because the configured gateway does not currently advertise a complete
- embedding provider/model pair, canonical Compose supplies
- the explicit `openai` / `text-embedding-3-large` pair to the orchestrator
- bootstrap. The bootstrap does not infer a provider from the model name; it
- publishes the pair's official 2,048-input,
- 8,192-token-per-input, and 300,000-total-token limits. LineageWeave clients
- remain provider- and model-neutral and consume that advertised capability.
+- Runtime discovery activates provider-declared chat and embedding capabilities.
+ LineageWeave does not configure or infer an embedding provider/model pair.
- A batch embedding request may omit `model`; contextual-orchestrator selects
an embedding-capable model and returns its identity for subsequent batches.
- A blank embedding input fails before provider selection; it is never sent as
diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py
index 63834c080..11004d29c 100644
--- a/tests/test_contextual_orchestrator_start.py
+++ b/tests/test_contextual_orchestrator_start.py
@@ -55,16 +55,6 @@ 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_embedding_agent_does_not_infer_provider_from_model_name() -> None:
- module = _load_start_module()
-
- agent = module._embedding_agent(
- "text-embedding-3-large", "", "https://gateway.example/v1"
- )
-
- assert "provider_name" not in agent
-
-
def test_provider_key_is_not_aliased_as_gateway_transport(monkeypatch) -> None:
module = _load_start_module()
for name in ("LLM_GATEWAY_API_KEY", "LLM_API_KEY"):
@@ -75,7 +65,7 @@ def test_provider_key_is_not_aliased_as_gateway_transport(monkeypatch) -> None:
module.main()
-def test_bootstrap_registers_operator_configured_embedding_capability(monkeypatch) -> None:
+def test_bootstrap_delegates_embedding_discovery_upstream(monkeypatch) -> None:
module = _load_start_module()
captured: dict[str, object] = {}
@@ -122,8 +112,6 @@ def serve() -> None:
monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_TOKEN", "orchestrator-token")
monkeypatch.setenv("LLM_GATEWAY_API_URL", "https://gateway.example")
monkeypatch.setenv("BATCH_JOB_REGISTRY_VALKEY_URL", "redis://valkey:6379/1")
- monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_MODEL", "text-embedding-3-large")
- monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_PROVIDER", "openai")
module.main()
@@ -152,17 +140,5 @@ def serve() -> None:
} & os.environ.keys()
agents = captured["agents"]
assert isinstance(agents, dict)
- assert [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])] == [
- {
- "id": "gateway_embedding_agent",
- "model": "text-embedding-3-large",
- "provider_protocol": "auto",
- "provider_name": "openai",
- "base_url": "https://gateway.example/v1",
- "credential_key": "LLM_GATEWAY_API_KEY",
- "tags": ["embedding"],
- "priority": 1,
- }
- ]
- assert "LLM_GATEWAY_EMBEDDING_MODEL" not in os.environ
- assert "LLM_GATEWAY_EMBEDDING_PROVIDER" not in os.environ
+ assert not [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])]
+ assert "--auto-discover-model-agents" in argv
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index 8c5ba3379..b673027d8 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "1cf837d8c6cd13cc66041f6da3c64500c474601e"
+ expected_embedding_contract_commit = "4212b25db9948c14f366124d92687383b3a0c712"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
diff --git a/tests/test_orchestrator_compose_embedding_contract.py b/tests/test_orchestrator_compose_embedding_contract.py
index 213e8a52d..85efd6996 100644
--- a/tests/test_orchestrator_compose_embedding_contract.py
+++ b/tests/test_orchestrator_compose_embedding_contract.py
@@ -12,8 +12,8 @@
_ROOT = Path(__file__).parents[1]
-def test_rendered_compose_owns_exact_embedding_pair(tmp_path: Path) -> None:
- """Render Compose and retain the gateway-owned exact embedding model."""
+def test_rendered_compose_keeps_embedding_selection_upstream(tmp_path: Path) -> None:
+ """Render Compose without a LineageWeave-owned embedding selector."""
(tmp_path / ".env").write_text("", encoding="utf-8")
environment = os.environ.copy()
environment["HOME"] = str(tmp_path)
@@ -31,8 +31,8 @@ def test_rendered_compose_owns_exact_embedding_pair(tmp_path: Path) -> None:
orchestrator_environment = config["services"]["orchestrator"]["environment"]
backend_environment = config["services"]["backend"]["environment"]
- assert orchestrator_environment["LLM_GATEWAY_EMBEDDING_MODEL"] == "text-embedding-3-large"
- assert orchestrator_environment["LLM_GATEWAY_EMBEDDING_PROVIDER"] == "openai"
+ assert "LLM_GATEWAY_EMBEDDING_MODEL" not in orchestrator_environment
+ assert "LLM_GATEWAY_EMBEDDING_PROVIDER" not in orchestrator_environment
assert (
orchestrator_environment["CONTEXTUAL_ORCHESTRATOR_TOKEN"]
== backend_environment["ORCHESTRATOR_API_KEY"]
@@ -45,5 +45,5 @@ def test_rendered_compose_owns_exact_embedding_pair(tmp_path: Path) -> None:
def test_lineage_clients_do_not_select_an_embedding_model() -> None:
"""Keep provider/model ownership outside LineageWeave client services."""
compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
- assert compose.count("LLM_GATEWAY_EMBEDDING_MODEL:") == 1
- assert compose.count("LLM_GATEWAY_EMBEDDING_PROVIDER:") == 1
+ assert "LLM_GATEWAY_EMBEDDING_MODEL:" not in compose
+ assert "LLM_GATEWAY_EMBEDDING_PROVIDER:" not in compose
From ce3869c715e8586ec9eff60120ed2756c0d83a97 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:37:30 +0900
Subject: [PATCH 172/393] chore(runtime): pin structured judge failover
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 56b9d918d..96fcb4a2a 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1cf837d8c6cd13cc66041f6da3c64500c474601e.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/d0e2ba9a4217a06abcf97dc0bce40e352346dbf6.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/1cf837d8c6cd13cc66041f6da3c64500c474601e.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/d0e2ba9a4217a06abcf97dc0bce40e352346dbf6.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 2da86481d..9e23ad281 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `1cf837d8c6cd13cc66041f6da3c64500c474601e`. The pin remains explicit
+commit `d0e2ba9a4217a06abcf97dc0bce40e352346dbf6`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index 8c5ba3379..2826a8d90 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "1cf837d8c6cd13cc66041f6da3c64500c474601e"
+ expected_embedding_contract_commit = "d0e2ba9a4217a06abcf97dc0bce40e352346dbf6"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 5c7e7cff11b3e3b8acff86c2a81252f0098b008d Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:50:02 +0900
Subject: [PATCH 173/393] chore(runtime): pin rebased embedding gateway
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 49c01a287..e10ef13c2 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/4212b25db9948c14f366124d92687383b3a0c712.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/2a1810f46bd0f2a648d2236266e2e5d54a6738bb.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/4212b25db9948c14f366124d92687383b3a0c712.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/2a1810f46bd0f2a648d2236266e2e5d54a6738bb.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 6969b78c1..ac3030f2f 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `4212b25db9948c14f366124d92687383b3a0c712`. The pin remains explicit
+commit `2a1810f46bd0f2a648d2236266e2e5d54a6738bb`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index b673027d8..269b6c3fe 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "4212b25db9948c14f366124d92687383b3a0c712"
+ expected_embedding_contract_commit = "2a1810f46bd0f2a648d2236266e2e5d54a6738bb"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 5b6f78e408109dd1bc88dcc24e79925f6bdd9068 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:55:16 +0900
Subject: [PATCH 174/393] fix(backfill): align current analysis selection
---
backend/app/post_content_queue.py | 1 +
tests/test_embedding_client.py | 50 +++++++++++++++++++++++++++++++
tests/test_post_content_queue.py | 1 +
3 files changed, 52 insertions(+)
diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py
index e01aad3ac..ffc8b3218 100644
--- a/backend/app/post_content_queue.py
+++ b/backend/app/post_content_queue.py
@@ -374,6 +374,7 @@ async def enqueue_post_content_backfill(
select 1
from operations_case_analysis analysis
where analysis.post_id = post.post_id
+ and analysis.source_body_sha256 = job.source_body_sha256
))
)
order by post.created_at, post.post_id
diff --git a/tests/test_embedding_client.py b/tests/test_embedding_client.py
index 54ff12803..833979964 100644
--- a/tests/test_embedding_client.py
+++ b/tests/test_embedding_client.py
@@ -47,6 +47,56 @@ def fake_get_json(url, *, headers, timeout, service_peer_name):
assert calls[2][2]["model"] == "resolved-embedding"
+def test_orchestrator_embedding_client_polls_through_pending_status(monkeypatch) -> None:
+ """A server-declared cadence remains mandatory on each pending poll envelope."""
+ responses = iter(
+ [
+ {
+ "batch_id": "synthetic-batch",
+ "status": "running",
+ "model": "resolved-embedding",
+ "poll_after_ms": 1,
+ "job_retention_ms": 60_000,
+ },
+ {
+ "batch_id": "synthetic-batch",
+ "status": "completed",
+ "model": "resolved-embedding",
+ "poll_after_ms": 1,
+ "job_retention_ms": 60_000,
+ "embeddings": [{"index": 0, "embedding": [1.0, 2.0]}],
+ },
+ ]
+ )
+ get_calls = []
+
+ def fake_post_json(url, payload, *, headers, timeout):
+ return {
+ "batch_id": "synthetic-batch",
+ "status": "queued",
+ "model": "resolved-embedding",
+ "poll_after_ms": 1,
+ "job_retention_ms": 60_000,
+ }
+
+ def fake_get_json(url, *, headers, timeout, service_peer_name):
+ get_calls.append(url)
+ return next(responses)
+
+ monkeypatch.setattr("lineageweave.embedding_client.post_json", fake_post_json)
+ monkeypatch.setattr("lineageweave.embedding_client.get_json", fake_get_json)
+ monkeypatch.setattr("lineageweave.embedding_client.time.sleep", lambda _seconds: None)
+ client = ContextualOrchestratorEmbeddingClient(
+ "http://orchestrator:8000", "synthetic-token"
+ )
+
+ assert client.embed_many(["first"]) == [[1.0, 2.0]]
+ assert get_calls == [
+ "http://orchestrator:8000/v1/batch/embeddings/synthetic-batch",
+ "http://orchestrator:8000/v1/batch/embeddings/synthetic-batch",
+ ]
+
+
def test_orchestrator_embedding_client_submits_index_aligned_provenance(monkeypatch) -> None:
"""Each bulk input carries its own source metadata and cost attribution."""
captured = {}
diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py
index 5a02b3b63..a672ef4e4 100644
--- a/tests/test_post_content_queue.py
+++ b/tests/test_post_content_queue.py
@@ -64,6 +64,7 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, str]]:
assert "job.post_id is null or job.status_code = $1" in query
assert "from operations_case_analysis analysis" in query
assert "analysis.post_id = post.post_id" in query
+ assert "analysis.source_body_sha256 = job.source_body_sha256" in query
assert "for update of post skip locked" in query.lower()
assert args == (SUCCEEDED, True, True, 2)
return [
From 32b38e9b724579151a761845fd416e4b47642530 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 16:57:08 +0900
Subject: [PATCH 175/393] chore(runtime): pin claimed embedding recovery
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index e10ef13c2..bb0e9e62f 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/2a1810f46bd0f2a648d2236266e2e5d54a6738bb.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/134c24f192ba7a319145d5ee9f39a77464e08cd8.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/2a1810f46bd0f2a648d2236266e2e5d54a6738bb.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/134c24f192ba7a319145d5ee9f39a77464e08cd8.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index ac3030f2f..4004d55b7 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `2a1810f46bd0f2a648d2236266e2e5d54a6738bb`. The pin remains explicit
+commit `134c24f192ba7a319145d5ee9f39a77464e08cd8`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index 269b6c3fe..899e78e7d 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "2a1810f46bd0f2a648d2236266e2e5d54a6738bb"
+ expected_embedding_contract_commit = "134c24f192ba7a319145d5ee9f39a77464e08cd8"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 05be18dbabc1b20d9f21a4483f94c76f1bc225b0 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 17:00:58 +0900
Subject: [PATCH 176/393] fix(runtime): defer unadmitted analysis jobs
---
backend/app/post_content_queue.py | 61 +++++++++++++++++-
backend/app/post_content_worker.py | 25 +++++++-
docker/contextual-orchestrator/Dockerfile | 4 +-
.../0083-orchestrator-runtime-commit-pin.md | 2 +-
...98-valkey-backed-post-content-ingestion.md | 16 +++++
lineageweave/http_client.py | 39 +++++++++++
.../0229_post_content_admission_deferral.sql | 7 ++
tests/test_documentation_hygiene.py | 2 +-
tests/test_http_client_edges.py | 49 ++++++++++++++
tests/test_post_content_queue.py | 64 +++++++++++++++++++
tests/test_post_content_worker.py | 58 +++++++++++++++++
11 files changed, 319 insertions(+), 8 deletions(-)
create mode 100644 migrations/0229_post_content_admission_deferral.sql
diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py
index e01aad3ac..2a37e8f1a 100644
--- a/backend/app/post_content_queue.py
+++ b/backend/app/post_content_queue.py
@@ -208,6 +208,7 @@ async def transition_post_content_job(
end,
completed_at = case when $2 in ($4, $5) then now() else null end,
queued_at = case when $2 = $6 then now() else queued_at end,
+ next_attempt_at = null,
updated_at = now(),
last_error_code = $7,
last_error_detail = $8
@@ -236,6 +237,53 @@ async def transition_post_content_job(
return True
+async def defer_post_content_job(
+ conn: asyncpg.Connection,
+ post_id: str,
+ *,
+ expected_attempt_count: int,
+ retry_after_seconds: int,
+) -> bool:
+ """Return one unadmitted lease to queued without consuming an attempt."""
+ if type(retry_after_seconds) is not int or retry_after_seconds <= 0:
+ raise ValueError("retry_after_seconds must be a positive integer")
+ updated = await conn.execute(
+ """
+ update post_content_ingestion_job
+ set status_code = $2,
+ attempt_count = attempt_count - 1,
+ queued_at = now(),
+ next_attempt_at = now() + make_interval(secs => $5),
+ started_at = null,
+ completed_at = null,
+ updated_at = now(),
+ last_error_code = $6,
+ last_error_detail = $7
+ where post_id = $1
+ and status_code = $3
+ and attempt_count = $4
+ and attempt_count > 0
+ """,
+ post_id,
+ QUEUED,
+ RUNNING,
+ expected_attempt_count,
+ retry_after_seconds,
+ "no_viable_agent",
+ "Analysis capacity is being restored; this record will retry automatically.",
+ )
+ if not updated.endswith(" 1"):
+ return False
+ await _record_status(
+ conn,
+ post_id,
+ QUEUED,
+ failure_code="no_viable_agent",
+ detail_text="Analysis capacity is being restored; this record will retry automatically.",
+ )
+ return True
+
+
async def ensure_post_content_job(
conn: asyncpg.Connection,
post_id: str,
@@ -287,6 +335,7 @@ async def ensure_post_content_job(
status_code = $3,
attempt_count = 0,
queued_at = now(),
+ next_attempt_at = null,
started_at = null,
completed_at = null,
updated_at = now(),
@@ -461,6 +510,7 @@ async def requeue_failed_post_content_job(
status_code = $3,
attempt_count = 0,
queued_at = now(),
+ next_attempt_at = null,
started_at = null,
completed_at = null,
updated_at = now(),
@@ -520,6 +570,7 @@ async def record_post_content_backfill_success(
status_code = $3,
started_at = null,
completed_at = now(),
+ next_attempt_at = null,
updated_at = now(),
last_error_code = null,
last_error_detail = null
@@ -553,8 +604,14 @@ async def republish_queued_post_content_jobs(
where (
status_code = $1
and (
- attempt_count = 0
- or queued_at <= now() - $2::interval
+ next_attempt_at <= now()
+ or (
+ next_attempt_at is null
+ and (
+ attempt_count = 0
+ or queued_at <= now() - $2::interval
+ )
+ )
)
)
or (
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 559108c59..8ac83928b 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -21,6 +21,7 @@
RUNNING,
STALE_RUNNING_INTERVAL,
SUCCEEDED,
+ defer_post_content_job,
ensure_post_content_job,
post_content_is_complete,
republish_queued_post_content_jobs,
@@ -36,7 +37,7 @@
gather_chat_sources,
)
from lineageweave.embedding_client import EmbeddingClient
-from lineageweave.http_client import HttpClientError
+from lineageweave.http_client import HttpAdmissionDeferred, HttpClientError
from lineageweave.image_content import ImageContentClient
from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata
from lineageweave.observability import record_server_failure, traced
@@ -303,6 +304,7 @@ async def _claim_job(
j.attempt_count as job_attempt_count,
j.started_at as job_started_at,
j.queued_at as job_queued_at,
+ j.next_attempt_at as job_next_attempt_at,
(
select analysis.source_body_sha256
from operations_case_analysis analysis
@@ -341,7 +343,14 @@ async def _claim_job(
detail_text="post-content ingestion attempt limit was already reached",
)
return None
- if status_code == QUEUED and attempt_count > 0:
+ if status_code == QUEUED and row["job_next_attempt_at"] is not None:
+ retry_ready = await conn.fetchval(
+ "select now() >= $1::timestamptz",
+ row["job_next_attempt_at"],
+ )
+ if not retry_ready:
+ return None
+ elif status_code == QUEUED and attempt_count > 0:
retry_ready = await conn.fetchval(
"select now() >= $1::timestamptz + $2::interval",
row["job_queued_at"],
@@ -522,6 +531,8 @@ async def process_post_content_job(
settings.orchestrator_api_key,
evidence_sources,
)
+ except HttpAdmissionDeferred:
+ raise
except (HttpClientError, OSError, RuntimeError, TimeoutError, ValueError) as exc:
_logger.error("product evidence ingestion failed for post_id=%s", post_id)
record_server_failure(
@@ -589,6 +600,16 @@ async def process_post_content_job(
exc,
outcome="provider_unavailable",
)
+ except HttpAdmissionDeferred as exc:
+ async with pool.acquire() as conn:
+ async with conn.transaction():
+ await defer_post_content_job(
+ conn,
+ post_id,
+ expected_attempt_count=attempt_count,
+ retry_after_seconds=exc.retry_after_seconds,
+ )
+ return
except Exception as exc: # noqa: BLE001 - durable failure is recorded for retry.
_logger.error("post content ingestion failed for post_id=%s", post_id)
outcome = (
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 49c01a287..418c896e2 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/4212b25db9948c14f366124d92687383b3a0c712.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/25760075ee64113e61de1ec676109b9f7567849b.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/4212b25db9948c14f366124d92687383b3a0c712.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/25760075ee64113e61de1ec676109b9f7567849b.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 6969b78c1..8c76b1468 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `4212b25db9948c14f366124d92687383b3a0c712`. The pin remains explicit
+commit `25760075ee64113e61de1ec676109b9f7567849b`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/docs/adr/0098-valkey-backed-post-content-ingestion.md b/docs/adr/0098-valkey-backed-post-content-ingestion.md
index c5098e9ea..ce2ce21d9 100644
--- a/docs/adr/0098-valkey-backed-post-content-ingestion.md
+++ b/docs/adr/0098-valkey-backed-post-content-ingestion.md
@@ -110,6 +110,22 @@ If Valkey is unavailable, the response reports `recovery_pending` and the
committed queued rows are republished by the existing recovery sweep. Direct
provider calls are not a substitute for the worker queue.
+## Provider admission deferral (2026-08-26)
+
+Contextual-orchestrator may return its typed `no_viable_agent` response before
+any provider inference is admitted. It supplies the same positive delay in the
+standard `Retry-After` header and its bounded error contract. This outcome is
+queue admission evidence, not a provider attempt or a negative analysis.
+
+The owning worker therefore uses a fenced PostgreSQL transition from the exact
+running lease back to queued, reverses only that lease's claim increment, and
+stores `next_attempt_at` from the orchestrator's exact delay. The post identity,
+body digest, post-scoped session, and existing evidence remain unchanged. A
+stale worker cannot defer a newer lease. Recovery publishes the row only after
+`next_attempt_at`; other transport, provider, validation, and persistence
+failures retain the existing three-attempt accounting. Raw upstream error text,
+agent identity, prompt, and response are neither stored nor shown to a reader.
+
### Operational timeout for structure adjudication
The contextual-orchestrator structure adjudication request uses a 600-second client timeout by default. Structure inference is an accuracy-critical, structured multi-agent operation rather than a user-facing synchronous request; the longer bound prevents a slow but valid workflow from being downgraded to `unresolved` merely because the client abandoned the response. The durable job remains queued until all non-image units have complete structure evidence.
diff --git a/lineageweave/http_client.py b/lineageweave/http_client.py
index 725b2d337..4c1cf92da 100644
--- a/lineageweave/http_client.py
+++ b/lineageweave/http_client.py
@@ -34,6 +34,14 @@ class HttpClientError(RuntimeError):
"""The remote endpoint failed, returned a non-success status, or invalid JSON."""
+class HttpAdmissionDeferred(HttpClientError):
+ """The orchestrator admitted no provider work and supplied an exact retry delay."""
+
+ def __init__(self, retry_after_seconds: int) -> None:
+ super().__init__("remote service has no viable agent yet")
+ self.retry_after_seconds = retry_after_seconds
+
+
def json_request_body(
payload: dict,
*,
@@ -163,6 +171,7 @@ def _request(
timeout: float,
maximum_response_bytes: int | None = None,
expected_response_media_type: str | None = None,
+ response_control_headers: dict[str, str] | None = None,
) -> tuple[int, bytes]:
"""Perform one bounded HTTP(S) request without exposing provider transport exception details."""
@@ -219,6 +228,10 @@ def _request(
response,
maximum_response_bytes=limit,
)
+ if response_control_headers is not None:
+ retry_after = response.getheader("Retry-After")
+ if retry_after is not None:
+ response_control_headers["retry-after"] = retry_after
return response.status, raw
except (OSError, ValueError, http.client.HTTPException) as exc:
# Chain internally for operator logging; the exposed
@@ -285,6 +298,7 @@ def post_json(
},
) as span:
inject_trace_context(request_headers)
+ response_control_headers: dict[str, str] = {}
status, raw = _request(
"POST",
url,
@@ -296,12 +310,37 @@ def post_json(
),
headers=request_headers,
timeout=timeout,
+ response_control_headers=response_control_headers,
)
if span is not None:
span.set_attribute("http.response.status_code", status)
if status >= 400:
if span is not None:
span.set_attribute("error.type", str(status))
+ if status == 503:
+ try:
+ error_payload = _decode_json_object(raw, hostname).get("error")
+ except HttpClientError:
+ error_payload = None
+ if (
+ isinstance(error_payload, dict)
+ and error_payload.get("code") == "no_viable_agent"
+ ):
+ detail = error_payload.get("detail")
+ retry_after = response_control_headers.get("retry-after", "")
+ detail_seconds = (
+ detail.get("retry_after_seconds")
+ if isinstance(detail, dict)
+ else None
+ )
+ if (
+ retry_after.isascii()
+ and retry_after.isdigit()
+ and int(retry_after) > 0
+ and type(detail_seconds) is int
+ and detail_seconds == int(retry_after)
+ ):
+ raise HttpAdmissionDeferred(detail_seconds)
raise HttpClientError(f"HTTP {status} from {hostname}")
try:
return _decode_json_object(raw, hostname)
diff --git a/migrations/0229_post_content_admission_deferral.sql b/migrations/0229_post_content_admission_deferral.sql
new file mode 100644
index 000000000..4505b182c
--- /dev/null
+++ b/migrations/0229_post_content_admission_deferral.sql
@@ -0,0 +1,7 @@
+-- ADR 0098 amendment: provider admission deferral is durable queue timing,
+-- not a consumed provider attempt.
+alter table post_content_ingestion_job
+ add column if not exists next_attempt_at timestamptz;
+
+create index if not exists post_content_ingestion_next_attempt_idx
+ on post_content_ingestion_job (status_code, next_attempt_at, queued_at);
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index b673027d8..516fbdfc6 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "4212b25db9948c14f366124d92687383b3a0c712"
+ expected_embedding_contract_commit = "25760075ee64113e61de1ec676109b9f7567849b"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
diff --git a/tests/test_http_client_edges.py b/tests/test_http_client_edges.py
index 5edcebf24..d22ae9aba 100644
--- a/tests/test_http_client_edges.py
+++ b/tests/test_http_client_edges.py
@@ -187,6 +187,55 @@ def capture_request(*_args: object, **kwargs: object) -> tuple[int, bytes]:
assert b'"lineageweave_post_id": "synthetic-post"' in captured_body
+def test_post_json_exposes_only_validated_admission_deferral(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The exact bounded retry contract becomes a typed control signal."""
+
+ def deferred_request(*_args: object, **kwargs: object) -> tuple[int, bytes]:
+ kwargs["response_control_headers"]["retry-after"] = "30"
+ return (
+ 503,
+ b'{"error":{"code":"no_viable_agent","detail":{"retry_after_seconds":30}}}',
+ )
+
+ monkeypatch.setattr(http_client, "_request", deferred_request)
+ with pytest.raises(http_client.HttpAdmissionDeferred) as captured:
+ http_client.post_json(
+ "https://gateway.example/v1/chat/completions",
+ {},
+ headers={},
+ timeout=1,
+ )
+
+ assert captured.value.retry_after_seconds == 30
+ assert "no_viable_agent" not in str(captured.value)
+
+
+def test_post_json_rejects_mismatched_admission_delay(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Conflicting header/body delays remain an ordinary unavailable response."""
+
+ def mismatched_request(*_args: object, **kwargs: object) -> tuple[int, bytes]:
+ kwargs["response_control_headers"]["retry-after"] = "31"
+ return (
+ 503,
+ b'{"error":{"code":"no_viable_agent","detail":{"retry_after_seconds":30}}}',
+ )
+
+ monkeypatch.setattr(http_client, "_request", mismatched_request)
+ with pytest.raises(http_client.HttpClientError, match="HTTP 503") as captured:
+ http_client.post_json(
+ "https://gateway.example/v1/chat/completions",
+ {},
+ headers={},
+ timeout=1,
+ )
+
+ assert not isinstance(captured.value, http_client.HttpAdmissionDeferred)
+
+
def test_request_preserves_the_url_query_in_the_http_target(
monkeypatch: pytest.MonkeyPatch,
) -> None:
diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py
index 5a02b3b63..8febec7cd 100644
--- a/tests/test_post_content_queue.py
+++ b/tests/test_post_content_queue.py
@@ -18,6 +18,7 @@
RUNNING,
SUCCEEDED,
PostContentJobRequest,
+ defer_post_content_job,
enqueue_post_content_backfill,
record_post_content_backfill_success,
requeue_failed_post_content_job,
@@ -628,11 +629,74 @@ async def xadd(self, _stream: str, fields: dict[str, str], **_kwargs: object) ->
assert published == 2
assert client.events == [("first", "a" * 64), ("second", "b" * 64)]
+ assert "next_attempt_at <= now()" in connection.query
assert "queued_at <= now() - $2::interval" in connection.query
assert "order by queued_at" in connection.query
assert connection.args == (QUEUED, POST_CONTENT_RETRY_INTERVAL, RUNNING, STALE_RUNNING_INTERVAL, 2)
+def test_admission_deferral_requeues_exact_lease_without_consuming_attempt() -> None:
+ """A readiness miss records timing and fences the running attempt."""
+ executed: list[tuple[str, tuple[object, ...]]] = []
+
+ class FakeConnection:
+ async def fetchval(self, query: str, *_args: object) -> int:
+ assert "status_ordinal" in query
+ return 2
+
+ async def execute(self, query: str, *args: object) -> str:
+ executed.append((query, args))
+ return "UPDATE 1" if query.lstrip().startswith("update") else "INSERT 0 1"
+
+ deferred = asyncio.run(
+ defer_post_content_job(
+ FakeConnection(),
+ "00000000-0000-0000-0000-000000000001",
+ expected_attempt_count=2,
+ retry_after_seconds=30,
+ )
+ )
+
+ assert deferred is True
+ update_query, update_args = executed[0]
+ assert "attempt_count = attempt_count - 1" in update_query
+ assert "status_code = $3" in update_query
+ assert "next_attempt_at = now() + make_interval(secs => $5)" in update_query
+ assert update_args[3:5] == (2, 30)
+ assert all("provider" not in str(args).casefold() for _query, args in executed)
+
+
+def test_admission_deferral_rejects_stale_lease_without_event() -> None:
+ """A reclaimed attempt cannot defer or append status for its replacement."""
+ executed: list[str] = []
+
+ class FakeConnection:
+ async def execute(self, query: str, *_args: object) -> str:
+ executed.append(query)
+ return "UPDATE 0"
+
+ deferred = asyncio.run(
+ defer_post_content_job(
+ FakeConnection(),
+ "00000000-0000-0000-0000-000000000001",
+ expected_attempt_count=1,
+ retry_after_seconds=30,
+ )
+ )
+
+ assert deferred is False
+ assert len(executed) == 1
+
+
+def test_admission_deferral_migration_is_replay_safe() -> None:
+ """The normalized retry instant is replay-safe and indexed for recovery."""
+ migration = (
+ _ROOT / "migrations" / "0229_post_content_admission_deferral.sql"
+ ).read_text()
+ assert "add column if not exists next_attempt_at timestamptz" in migration
+ assert "create index if not exists post_content_ingestion_next_attempt_idx" in migration
+
+
def test_migration_contains_normalized_job_and_status_event_tables() -> None:
migration = (_ROOT / "migrations" / "0050_post_content_ingestion_queue.sql").read_text()
assert "create table if not exists post_content_ingestion_job" in migration
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index 8b607b266..e0bf3f60b 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -19,6 +19,7 @@
SUCCEEDED,
)
from lineageweave.operations_case_analysis import OperationsEvidenceSource
+from lineageweave.http_client import HttpAdmissionDeferred
_PRODUCT_ANALYSIS = post_content_worker._persist_product_analysis_if_needed
@@ -83,6 +84,7 @@ def _row(status: str, attempt_count: int, *, started_at: object = None) -> dict[
"job_attempt_count": attempt_count,
"job_started_at": started_at,
"job_queued_at": "queued-at",
+ "job_next_attempt_at": None,
"post_body": "A synthetic post body with a retrieval unit.",
"post_title": "Synthetic post title",
}
@@ -835,6 +837,62 @@ async def persist(*_args, **_kwargs):
assert record.failure_outcome == "provider_unavailable"
+def test_no_viable_agent_defers_without_consuming_failure_budget(monkeypatch) -> None:
+ """Provider admission refusal uses the exact durable deferral transition."""
+ connection = _Connection()
+ pool = _Pool(connection)
+ deferred: list[tuple[int, int]] = []
+
+ async def claim(*_args, **_kwargs):
+ return _row(RUNNING, 0)
+
+ async def no_viable(*_args, **_kwargs):
+ raise HttpAdmissionDeferred(30)
+
+ async def evidence_sources(*_args, **_kwargs):
+ return ()
+
+ async def defer(*_args, expected_attempt_count: int, retry_after_seconds: int, **_kwargs):
+ deferred.append((expected_attempt_count, retry_after_seconds))
+ return True
+
+ monkeypatch.setattr(post_content_worker, "_claim_job", claim)
+ monkeypatch.setattr(
+ post_content_worker,
+ "_persist_operations_case_analysis_if_needed",
+ no_viable,
+ )
+ monkeypatch.setattr(
+ post_content_worker,
+ "_operations_evidence_sources",
+ evidence_sources,
+ )
+ monkeypatch.setattr(post_content_worker, "defer_post_content_job", defer)
+ monkeypatch.setattr(
+ post_content_worker,
+ "load_settings",
+ lambda: SimpleNamespace(
+ orchestrator_base_url="http://orchestrator",
+ orchestrator_api_key="synthetic-token",
+ ),
+ )
+ client = SimpleNamespace(available=True)
+
+ asyncio.run(
+ post_content_worker.process_post_content_job(
+ pool,
+ post_id="00000000-0000-0000-0000-000000000001",
+ source_body_digest="a" * 64,
+ vision_factory=lambda: client,
+ embedding_factory=lambda: client,
+ structure_factory=lambda: client,
+ )
+ )
+
+ assert deferred == [(1, 30)]
+ assert not any("post_content_ingestion_failed" in str(args) for _, args in connection.executed)
+
+
def test_unexpected_worker_error_is_classified_as_internal(monkeypatch, caplog) -> None:
"""Unexpected worker defects stay internal while their value remains private."""
caplog.set_level("ERROR", logger="lineageweave.observability")
From a116d5701c8e29683adfd9c13c454199dfa50fd6 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 17:02:28 +0900
Subject: [PATCH 177/393] chore: advance orchestrator embedding owner pin
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index bb0e9e62f..e1bdfea47 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/134c24f192ba7a319145d5ee9f39a77464e08cd8.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/19f870d46bcbc7ac81c4288d0ff3f7f847712fad.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/134c24f192ba7a319145d5ee9f39a77464e08cd8.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/19f870d46bcbc7ac81c4288d0ff3f7f847712fad.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index 4004d55b7..d316625b4 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `134c24f192ba7a319145d5ee9f39a77464e08cd8`. The pin remains explicit
+commit `19f870d46bcbc7ac81c4288d0ff3f7f847712fad`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index 899e78e7d..aecf8962b 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "134c24f192ba7a319145d5ee9f39a77464e08cd8"
+ expected_embedding_contract_commit = "19f870d46bcbc7ac81c4288d0ff3f7f847712fad"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From a60df95289f461d665d41c63666194948d7fa8eb Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 17:13:51 +0900
Subject: [PATCH 178/393] fix(database): repair interrupted concurrent indexes
---
.../0166-idempotent-migration-replay-window.md | 6 ++++++
migrations/0035_body_search_prefix.sql | 16 ++++++++++++++++
tests/test_post_content_queue.py | 10 ++++++++++
3 files changed, 32 insertions(+)
diff --git a/docs/adr/0166-idempotent-migration-replay-window.md b/docs/adr/0166-idempotent-migration-replay-window.md
index d28402453..e7aae44bc 100644
--- a/docs/adr/0166-idempotent-migration-replay-window.md
+++ b/docs/adr/0166-idempotent-migration-replay-window.md
@@ -29,6 +29,12 @@ notation is an optional extension and cannot be required by this script.
PostgreSQL idempotency such as `IF NOT EXISTS` and `ON CONFLICT`; a migration
that cannot be made idempotent requires a migration ledger ADR before it is
added.
+- An interrupted `CREATE INDEX CONCURRENTLY` can retain an invalid catalog
+ object that `IF NOT EXISTS` would incorrectly accept on every later replay.
+ A migration owning such an index must query `pg_index.indisvalid`, drop only
+ its own invalid index concurrently through `psql` `\gexec`, and then replay
+ the idempotent create. Valid indexes remain untouched, so ordinary startup
+ does not rebuild them.
- Execute each accepted file with `psql -X -v ON_ERROR_STOP=1`. A failed
migration stops startup instead of leaving a healthy-looking partial schema.
- Tests must cover the stable 0012 boundary and the idempotency of any changed
diff --git a/migrations/0035_body_search_prefix.sql b/migrations/0035_body_search_prefix.sql
index cc0114ec3..df7fed5fd 100644
--- a/migrations/0035_body_search_prefix.sql
+++ b/migrations/0035_body_search_prefix.sql
@@ -2,6 +2,22 @@
-- source body. The detail endpoint still returns the complete post_body.
create extension if not exists pg_trgm;
+-- CREATE INDEX CONCURRENTLY leaves an invalid catalog row when its builder is
+-- interrupted. IF NOT EXISTS would then skip that unusable object forever.
+-- Drop only this migration's invalid owned indexes, outside a transaction,
+-- before replaying the normal idempotent creates.
+select format('drop index concurrently if exists %I.%I', namespace.nspname, class.relname)
+from pg_index index_state
+join pg_class class on class.oid = index_state.indexrelid
+join pg_namespace namespace on namespace.oid = class.relnamespace
+where namespace.nspname = 'public'
+ and class.relname in (
+ 'source_post_body_prefix_trgm_idx',
+ 'source_post_body_fts_idx'
+ )
+ and not index_state.indisvalid
+\gexec
+
create index concurrently if not exists source_post_body_prefix_trgm_idx
on source_post using gin (
lower(left(coalesce(post_body, ''), 16384)) gin_trgm_ops
diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py
index 971f63bd7..d0d1c57a6 100644
--- a/tests/test_post_content_queue.py
+++ b/tests/test_post_content_queue.py
@@ -714,3 +714,13 @@ def test_migration_replay_window_includes_post_content_queue() -> None:
# 0050 therefore clears the fixed lower-bound filename gate.
assert "000[0-9]_*|001[01]_*) continue" in migrate
assert "[0-9][0-9][0-9][0-9]_*)" in migrate
+
+
+def test_concurrent_search_index_replay_repairs_only_invalid_owned_indexes() -> None:
+ """An interrupted concurrent build cannot poison every later replay."""
+ migration = (_ROOT / "migrations" / "0035_body_search_prefix.sql").read_text()
+ assert "not index_state.indisvalid" in migration
+ assert "drop index concurrently if exists %I.%I" in migration
+ assert "\\gexec" in migration
+ assert migration.count("source_post_body_prefix_trgm_idx") == 2
+ assert migration.count("source_post_body_fts_idx") == 2
From 8163ba98ce78690b34118b9133efb21f4f5f7b39 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 17:17:39 +0900
Subject: [PATCH 179/393] fix(database): stop rebuilding superseded search
indexes
---
...0166-idempotent-migration-replay-window.md | 13 ++++----
migrations/0035_body_search_prefix.sql | 32 +++----------------
tests/test_post_content_queue.py | 20 +++++++-----
3 files changed, 23 insertions(+), 42 deletions(-)
diff --git a/docs/adr/0166-idempotent-migration-replay-window.md b/docs/adr/0166-idempotent-migration-replay-window.md
index e7aae44bc..0980aada0 100644
--- a/docs/adr/0166-idempotent-migration-replay-window.md
+++ b/docs/adr/0166-idempotent-migration-replay-window.md
@@ -29,12 +29,13 @@ notation is an optional extension and cannot be required by this script.
PostgreSQL idempotency such as `IF NOT EXISTS` and `ON CONFLICT`; a migration
that cannot be made idempotent requires a migration ledger ADR before it is
added.
-- An interrupted `CREATE INDEX CONCURRENTLY` can retain an invalid catalog
- object that `IF NOT EXISTS` would incorrectly accept on every later replay.
- A migration owning such an index must query `pg_index.indisvalid`, drop only
- its own invalid index concurrently through `psql` `\gexec`, and then replay
- the idempotent create. Valid indexes remain untouched, so ordinary startup
- does not rebuild them.
+- A later replayed migration that supersedes and drops an earlier index also
+ supersedes that earlier migration's create operation. The earlier file keeps
+ its sorted schema boundary but must not recreate a corpus-wide index that the
+ next file immediately drops. The current body-search example keeps the
+ `pg_trgm` extension in 0035 while 0036 solely owns the normalized search
+ indexes. This avoids a complete GIN build/drop cycle on every startup without
+ skipping the successor's correctness boundary.
- Execute each accepted file with `psql -X -v ON_ERROR_STOP=1`. A failed
migration stops startup instead of leaving a healthy-looking partial schema.
- Tests must cover the stable 0012 boundary and the idempotency of any changed
diff --git a/migrations/0035_body_search_prefix.sql b/migrations/0035_body_search_prefix.sql
index df7fed5fd..13f1db869 100644
--- a/migrations/0035_body_search_prefix.sql
+++ b/migrations/0035_body_search_prefix.sql
@@ -1,29 +1,5 @@
--- Keep body search indexed without duplicating the full, potentially very large
--- source body. The detail endpoint still returns the complete post_body.
+-- Historical boundary retained for sorted replay. Migration 0036 supersedes
+-- both original body indexes with image-safe normalized search indexes, so
+-- recreating the obsolete indexes here would make every replay build and then
+-- immediately drop two corpus-wide GIN indexes.
create extension if not exists pg_trgm;
-
--- CREATE INDEX CONCURRENTLY leaves an invalid catalog row when its builder is
--- interrupted. IF NOT EXISTS would then skip that unusable object forever.
--- Drop only this migration's invalid owned indexes, outside a transaction,
--- before replaying the normal idempotent creates.
-select format('drop index concurrently if exists %I.%I', namespace.nspname, class.relname)
-from pg_index index_state
-join pg_class class on class.oid = index_state.indexrelid
-join pg_namespace namespace on namespace.oid = class.relnamespace
-where namespace.nspname = 'public'
- and class.relname in (
- 'source_post_body_prefix_trgm_idx',
- 'source_post_body_fts_idx'
- )
- and not index_state.indisvalid
-\gexec
-
-create index concurrently if not exists source_post_body_prefix_trgm_idx
- on source_post using gin (
- lower(left(coalesce(post_body, ''), 16384)) gin_trgm_ops
- );
-
-create index concurrently if not exists source_post_body_fts_idx
- on source_post using gin (
- to_tsvector('simple', coalesce(post_body, ''))
- );
diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py
index d0d1c57a6..eb81020ea 100644
--- a/tests/test_post_content_queue.py
+++ b/tests/test_post_content_queue.py
@@ -716,11 +716,15 @@ def test_migration_replay_window_includes_post_content_queue() -> None:
assert "[0-9][0-9][0-9][0-9]_*)" in migrate
-def test_concurrent_search_index_replay_repairs_only_invalid_owned_indexes() -> None:
- """An interrupted concurrent build cannot poison every later replay."""
- migration = (_ROOT / "migrations" / "0035_body_search_prefix.sql").read_text()
- assert "not index_state.indisvalid" in migration
- assert "drop index concurrently if exists %I.%I" in migration
- assert "\\gexec" in migration
- assert migration.count("source_post_body_prefix_trgm_idx") == 2
- assert migration.count("source_post_body_fts_idx") == 2
+def test_superseded_body_indexes_are_not_rebuilt_before_normalized_search() -> None:
+ """Replay never builds legacy GIN indexes that the successor drops."""
+ migration_0035 = (
+ _ROOT / "migrations" / "0035_body_search_prefix.sql"
+ ).read_text()
+ migration_0036 = (
+ _ROOT / "migrations" / "0036_normalized_body_search.sql"
+ ).read_text()
+ assert "create extension if not exists pg_trgm" in migration_0035
+ assert "create index" not in migration_0035.casefold()
+ assert "create index if not exists source_post_search_prefix_trgm_idx" in migration_0036
+ assert "create index if not exists source_post_search_fts_idx" in migration_0036
From d502ee44e5381029506e657b3e15d6519ad1756e Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 17:17:46 +0900
Subject: [PATCH 180/393] chore: advance orchestrator embedding owner pin
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index e1bdfea47..4d0a18ce3 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/19f870d46bcbc7ac81c4288d0ff3f7f847712fad.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/4dcf952a38b0b303137813aea5a59aea727b6434.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/19f870d46bcbc7ac81c4288d0ff3f7f847712fad.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/4dcf952a38b0b303137813aea5a59aea727b6434.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index d316625b4..f626c3211 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `19f870d46bcbc7ac81c4288d0ff3f7f847712fad`. The pin remains explicit
+commit `4dcf952a38b0b303137813aea5a59aea727b6434`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index aecf8962b..c205d804a 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "19f870d46bcbc7ac81c4288d0ff3f7f847712fad"
+ expected_embedding_contract_commit = "4dcf952a38b0b303137813aea5a59aea727b6434"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 6b9d74d27e7e1e49654b400f5b18fdff0b79beed Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 17:24:07 +0900
Subject: [PATCH 181/393] fix(products): backfill complete historical posts
---
backend/app/post_content_queue.py | 8 ++++++++
backend/app/post_content_worker.py | 17 +++++++++++++++--
tests/test_post_content_queue.py | 2 ++
tests/test_post_content_worker.py | 27 +++++++++++++++++++++++++++
4 files changed, 52 insertions(+), 2 deletions(-)
diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py
index 083494de3..67a169e34 100644
--- a/backend/app/post_content_queue.py
+++ b/backend/app/post_content_queue.py
@@ -425,6 +425,12 @@ async def enqueue_post_content_backfill(
where analysis.post_id = post.post_id
and analysis.source_body_sha256 = job.source_body_sha256
))
+ or ($3::boolean and not exists (
+ select 1
+ from post_product_analysis analysis
+ where analysis.post_id = post.post_id
+ and analysis.source_body_sha256 = job.source_body_sha256
+ ))
)
order by post.created_at, post.post_id
limit $4
@@ -454,6 +460,8 @@ async def enqueue_post_content_backfill(
complete = bool(
await conn.fetchval(
"select exists (select 1 from operations_case_analysis "
+ "where post_id = $1 and source_body_sha256 = $2) "
+ "and exists (select 1 from post_product_analysis "
"where post_id = $1 and source_body_sha256 = $2)",
post_id,
source_body_sha256(body),
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index 8ac83928b..f3ffee33b 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -309,7 +309,12 @@ async def _claim_job(
select analysis.source_body_sha256
from operations_case_analysis analysis
where analysis.post_id = p.post_id
- ) as case_analysis_source_body_sha256
+ ) as case_analysis_source_body_sha256,
+ (
+ select analysis.source_body_sha256
+ from post_product_analysis analysis
+ where analysis.post_id = p.post_id
+ ) as product_analysis_source_body_sha256
from post_content_ingestion_job j
join source_post p on p.post_id = j.post_id
where j.post_id = $1::uuid
@@ -373,7 +378,15 @@ async def _claim_job(
source_body_digest,
)
)
- if content_complete and case_complete:
+ if (
+ content_complete
+ and case_complete
+ and (
+ not require_structure
+ or row["product_analysis_source_body_sha256"]
+ == source_body_digest
+ )
+ ):
return None
if status_code == RUNNING and row["job_started_at"] is not None:
stale = await conn.fetchval(
diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py
index eb81020ea..8d47598a0 100644
--- a/tests/test_post_content_queue.py
+++ b/tests/test_post_content_queue.py
@@ -66,6 +66,7 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, str]]:
assert "from operations_case_analysis analysis" in query
assert "analysis.post_id = post.post_id" in query
assert "analysis.source_body_sha256 = job.source_body_sha256" in query
+ assert "from post_product_analysis analysis" in query
assert "for update of post skip locked" in query.lower()
assert args == (SUCCEEDED, True, True, 2)
return [
@@ -203,6 +204,7 @@ async def fetch(self, _query: str, *_args: object) -> list[dict[str, str]]:
async def fetchval(self, query: str, *args: object) -> bool:
assert "operations_case_analysis" in query
+ assert "post_product_analysis" in query
assert args == (
"00000000-0000-0000-0000-000000000001",
source_body_sha256("historical success"),
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index e0bf3f60b..81c7bd773 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -319,6 +319,33 @@ async def incomplete(*_args, **_kwargs) -> bool:
assert calls == ["checked"]
+def test_successful_job_reclaims_when_product_analysis_is_missing(monkeypatch) -> None:
+ """Historical content is reclaimed until its exact product analysis exists."""
+ row = _row(SUCCEEDED, 0)
+ row["product_analysis_source_body_sha256"] = None
+ connection = _Connection(row, values=[True])
+
+ async def complete(*_args, **_kwargs) -> bool:
+ return True
+
+ monkeypatch.setattr(post_content_worker, "post_content_is_complete", complete)
+ claimed = asyncio.run(
+ post_content_worker._claim_job(
+ _Pool(connection),
+ "00000000-0000-0000-0000-000000000001",
+ "a" * 64,
+ require_embedding=True,
+ require_structure=True,
+ )
+ )
+
+ assert claimed is row
+ assert any(
+ "attempt_count = attempt_count + 1" in query
+ for query, _args in connection.executed
+ )
+
+
def test_incomplete_provider_output_is_requeued_with_a_failure_code(monkeypatch) -> None:
connection = _Connection(values=[False, 2])
pool = _Pool(connection)
From 9377b84843f479c0c79d44129736d6cbe08c2a91 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 17:26:08 +0900
Subject: [PATCH 182/393] fix(embeddings): forbid caller-selected models
---
lineageweave/embedding_client.py | 7 +++----
tests/test_embedding_client_edges.py | 25 ++++++++++++++++++-------
2 files changed, 21 insertions(+), 11 deletions(-)
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index 1a280f8be..fc55dead8 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -45,9 +45,9 @@ class OpenAiCompatibleEmbeddingClient:
available = True
- def __init__(self, base_url: str, api_key: str, model: str | None = None, *, timeout: float = 30.0) -> None:
+ def __init__(self, base_url: str, api_key: str, *, timeout: float = 30.0) -> None:
self._delegate = ContextualOrchestratorEmbeddingClient(
- base_url, api_key, model, timeout=timeout
+ base_url, api_key, timeout=timeout
)
def embed(self, text: str) -> list[float]:
@@ -69,7 +69,6 @@ def __init__(
self,
base_url: str,
api_key: str,
- model: str | None = None,
*,
timeout: float = 60.0,
poll_interval: float = 0.25,
@@ -78,7 +77,7 @@ def __init__(
if not self._base_url.endswith("/v1"):
self._base_url = f"{self._base_url}/v1"
self._api_key = api_key
- self._model = model or None
+ self._model: str | None = None
self._timeout = timeout
self._poll_interval = poll_interval
diff --git a/tests/test_embedding_client_edges.py b/tests/test_embedding_client_edges.py
index 97e90a9e1..81ac56f58 100644
--- a/tests/test_embedding_client_edges.py
+++ b/tests/test_embedding_client_edges.py
@@ -17,14 +17,14 @@ def test_missing_embedding_configuration_returns_null_client() -> None:
def test_empty_batch_does_not_call_orchestrator(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(embedding_client, "post_json", lambda *_args, **_kwargs: pytest.fail("unexpected call"))
- client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", "model")
+ client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key")
assert client.embed_many([]) == []
@pytest.mark.parametrize("field", ["input_attributions", "input_metadata"])
def test_per_input_context_must_align_with_texts(field: str) -> None:
client = embedding_client.ContextualOrchestratorEmbeddingClient(
- "http://orchestrator", "key", "model"
+ "http://orchestrator", "key"
)
with pytest.raises(ValueError, match=field):
@@ -69,7 +69,7 @@ def test_immediate_embedding_response_is_ordered(monkeypatch: pytest.MonkeyPatch
]
},
)
- client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator/v1", "key", "model")
+ client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator/v1", "key")
assert client.embed_many(["a", "b"]) == [[1.0], [2.0]]
@@ -94,7 +94,7 @@ def test_batch_response_polls_until_complete(monkeypatch: pytest.MonkeyPatch) ->
)
monkeypatch.setattr(embedding_client.time, "sleep", lambda _seconds: None)
monkeypatch.setattr(embedding_client.time, "monotonic", lambda: 0.0)
- client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", "model", timeout=1)
+ client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", timeout=1)
assert client.embed_many(["a"]) == [[0.5]]
@@ -110,7 +110,7 @@ def test_failed_batch_raises_without_fallback(monkeypatch: pytest.MonkeyPatch) -
"job_retention_ms": 60_000,
},
)
- client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", "model")
+ client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key")
with pytest.raises(RuntimeError, match="did not complete"):
client.embed_many(["a"])
@@ -128,7 +128,7 @@ def test_batch_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None:
},
)
monkeypatch.setattr(embedding_client.time, "monotonic", iter([0.0, 2.0]).__next__)
- client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", "model", timeout=1)
+ client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", timeout=1)
with pytest.raises(TimeoutError, match="timed out"):
client.embed_many(["a"])
@@ -169,10 +169,21 @@ def embed(self, text: str) -> list[float]:
return [float(len(text))]
monkeypatch.setattr(embedding_client, "ContextualOrchestratorEmbeddingClient", Delegate)
- client = embedding_client.OpenAiCompatibleEmbeddingClient("http://orchestrator", "key", "model")
+ client = embedding_client.OpenAiCompatibleEmbeddingClient("http://orchestrator", "key")
assert client.embed("abc") == [3.0]
+def test_embedding_clients_do_not_accept_a_caller_selected_model() -> None:
+ with pytest.raises(TypeError):
+ embedding_client.ContextualOrchestratorEmbeddingClient(
+ "http://orchestrator", "key", "caller-model"
+ )
+ with pytest.raises(TypeError):
+ embedding_client.OpenAiCompatibleEmbeddingClient(
+ "http://orchestrator", "key", "caller-model"
+ )
+
+
def test_batch_body_size_matches_post_scoped_orchestrator_wire_body() -> None:
"""The advertised ceiling includes the injected post session field."""
client = embedding_client.ContextualOrchestratorEmbeddingClient(
From ae129154ebfbf16301f4de1bc01ab4ffaa0f5e49 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 17:28:02 +0900
Subject: [PATCH 183/393] chore: pin request-scoped readiness runtime
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 4d0a18ce3..5d25fa251 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/4dcf952a38b0b303137813aea5a59aea727b6434.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/63220f263fe10dfc2e191e5e5b276c5287e2eedf.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/4dcf952a38b0b303137813aea5a59aea727b6434.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/63220f263fe10dfc2e191e5e5b276c5287e2eedf.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index f626c3211..f5ef3b857 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `4dcf952a38b0b303137813aea5a59aea727b6434`. The pin remains explicit
+commit `63220f263fe10dfc2e191e5e5b276c5287e2eedf`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index c205d804a..23e14092e 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "4dcf952a38b0b303137813aea5a59aea727b6434"
+ expected_embedding_contract_commit = "63220f263fe10dfc2e191e5e5b276c5287e2eedf"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 0feb7f72cc34fbd23e8f1bd6355c9a5ee49ded89 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 26 Aug 2026 01:34:10 -0700
Subject: [PATCH 184/393] feat: preserve overlapping voice semantic
classifications (#694)
* feat(ontology): preserve overlapping voice classifications
* fix(ontology): align voice summary authorization
* fix(ui): keep voice evidence copy customer-facing
* fix(dashboard): align voice period semantics
* fix(dashboard): remove unused voice response copy
* fix: enforce voice evidence boundaries
* fix(voice): preserve active agreement evidence
* fix(ontology): validate voice assertion evidence
* fix(voice): keep source labels available
* fix(voice): reconcile source assertions at ingestion
* test: isolate future voice validity
* docs: audit Rust ownership across dashboard stack
* docs: refresh Rust boundary delivery evidence
* fix: keep voice evidence independent and unique
* fix: name voice evidence metrics precisely
* docs: complete Python compute debt inventory
* fix: preserve sourced voice memberships on reconcile
* fix: keep voice migration order unique
* fix(ui): unify dashboard loading announcement
* fix(voice): exclude demo rows from real summaries
---------
Co-authored-by: Codex
---
backend/app/main.py | 66 +++++
backend/app/voice_taxonomy.py | 111 ++++++++
backend/tests/test_api.py | 251 +++++++++++++++++-
backend/tests/test_voice_taxonomy.py | 126 +++++++++
...urce-preserving-voice-semantic-taxonomy.md | 63 +++++
docs/adr/README.md | 1 +
...hon-mathematical-compute-boundary-audit.md | 35 +++
docs/ontology/lineageweave-kg-shapes.ttl | 59 ++++
docs/ontology/lineageweave-kg.ttl | 33 +++
docs/product-requirements.md | 7 +
docs/product-technical-gap-baseline.md | 3 +-
docs/storybook-inventory.md | 3 +-
frontend/src/api.ts | 28 ++
.../OperationsDashboard.stories.tsx | 38 +++
.../components/OperationsDashboard.test.tsx | 66 ++++-
.../src/components/OperationsDashboard.tsx | 36 ++-
.../VoiceTaxonomySummary.stories.tsx | 16 ++
.../components/VoiceTaxonomySummary.test.tsx | 19 ++
.../src/components/VoiceTaxonomySummary.tsx | 36 +++
frontend/src/i18n.test.ts | 12 +
frontend/src/i18n.ts | 52 ++++
migrations/0230_voice_semantic_taxonomy.sql | 200 ++++++++++++++
tests/test_ontology_shapes.py | 28 ++
23 files changed, 1279 insertions(+), 10 deletions(-)
create mode 100644 backend/app/voice_taxonomy.py
create mode 100644 backend/tests/test_voice_taxonomy.py
create mode 100644 docs/adr/0230-source-preserving-voice-semantic-taxonomy.md
create mode 100644 frontend/src/components/VoiceTaxonomySummary.stories.tsx
create mode 100644 frontend/src/components/VoiceTaxonomySummary.test.tsx
create mode 100644 frontend/src/components/VoiceTaxonomySummary.tsx
create mode 100644 migrations/0230_voice_semantic_taxonomy.sql
diff --git a/backend/app/main.py b/backend/app/main.py
index 1388387c4..6fdf6d4f3 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -42,6 +42,7 @@
ticket_created_summary,
ticket_status_changed_summary,
)
+from backend.app.voice_taxonomy import load_voice_taxonomy_summary
from backend.app.affiliate_tree_ingestion import (
fetch_affiliate_forest,
fetch_voc_evidence,
@@ -1542,6 +1543,71 @@ async def list_posts(
}
+@app.get("/api/voice-taxonomy/summary")
+async def read_voice_taxonomy_summary(
+ date_from: date | None = None,
+ date_to: date | None = None,
+ corporate_entity_id: UUID | None = None,
+ process_unit_id: UUID | None = None,
+ team_id: UUID | None = None,
+ person_id: UUID | None = None,
+ product_catalog_id: UUID | None = None,
+ project_key: str | None = Query(default=None, max_length=200),
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, Any]:
+ """Return overlapping source/derived voice counts for the selected scope."""
+ _require_post_read(account)
+ if date_from is not None and date_to is not None and date_to < date_from:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_CONTENT,
+ "Choose an end time after the start time, then review the updated scope.",
+ )
+ async with pool.acquire() as conn:
+ excluded_entity_ids: tuple[str, ...] = ()
+ if await has_real_source_context(conn, list(account.corporate_entity_ids)):
+ excluded_entity_ids = tuple(
+ sorted(await fetch_demo_corporate_entity_ids(conn))
+ )
+ summary = await load_voice_taxonomy_summary(
+ conn,
+ authorized_corporate_entity_ids=tuple(
+ str(value) for value in account.corporate_entity_ids
+ ),
+ authorized_process_unit_ids=tuple(
+ str(value) for value in account.process_unit_ids
+ ),
+ date_from=date_from,
+ date_to=date_to,
+ corporate_entity_id=str(corporate_entity_id) if corporate_entity_id else None,
+ process_unit_id=str(process_unit_id) if process_unit_id else None,
+ team_id=str(team_id) if team_id else None,
+ person_id=str(person_id) if person_id else None,
+ product_catalog_id=str(product_catalog_id) if product_catalog_id else None,
+ project_key=project_key.strip() if project_key and project_key.strip() else None,
+ excluded_corporate_entity_ids=excluded_entity_ids,
+ )
+ total = int(summary["total_eligible"])
+ raw_category_counts = summary["category_post_counts"]
+ category_counts = (
+ json.loads(raw_category_counts)
+ if isinstance(raw_category_counts, str)
+ else dict(raw_category_counts)
+ )
+ return {
+ **{key: value for key, value in summary.items() if key != "category_post_counts"},
+ "category_memberships": [
+ {
+ "voice_concept_code": code,
+ "post_count": int(count),
+ "eligible_percentage": (float(count) / total * 100.0) if total else 0.0,
+ }
+ for code, count in sorted(category_counts.items())
+ ],
+ "counts_overlap": True,
+ }
+
+
@app.get("/api/posts/{post_id}")
async def read_post(
post_id: str,
diff --git a/backend/app/voice_taxonomy.py b/backend/app/voice_taxonomy.py
new file mode 100644
index 000000000..cea93cfeb
--- /dev/null
+++ b/backend/app/voice_taxonomy.py
@@ -0,0 +1,111 @@
+"""Authorized aggregate reads for source-preserving voice assertions."""
+
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
+
+
+class _Connection(Protocol):
+ async def fetchrow(self, query: str, *args: object) -> Any:
+ """Fetch one aggregate row with bound parameters."""
+ pass # pragma: no cover - structural protocol declaration
+
+
+async def load_voice_taxonomy_summary(
+ conn: _Connection,
+ *,
+ authorized_corporate_entity_ids: tuple[str, ...],
+ authorized_process_unit_ids: tuple[str, ...],
+ date_from: Any = None,
+ date_to: Any = None,
+ corporate_entity_id: str | None = None,
+ process_unit_id: str | None = None,
+ team_id: str | None = None,
+ person_id: str | None = None,
+ product_catalog_id: str | None = None,
+ project_key: str | None = None,
+ excluded_corporate_entity_ids: tuple[str, ...] = (),
+) -> dict[str, Any]:
+ """Count overlapping voice memberships over one authorized denominator."""
+ row = await conn.fetchrow(
+ f"""
+ with eligible as (
+ select post.post_id
+ from source_post post
+ where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
+ and (post.visibility_code = 'public'
+ or (post.corporate_entity_id = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or post.process_unit_id = any($2::uuid[]))))
+ and ($3::date is null or timezone('Asia/Seoul', coalesce(post.event_occurred_at, post.created_at))::date >= $3)
+ and ($4::date is null or timezone('Asia/Seoul', coalesce(post.event_occurred_at, post.created_at))::date <= $4)
+ and ($5::uuid is null or post.corporate_entity_id = $5)
+ and ($6::uuid is null or post.process_unit_id = $6)
+ and ($7::uuid is null or exists (
+ select 1 from post_team_mention team
+ where team.post_id = post.post_id and team.team_id = $7))
+ and ($8::uuid is null or exists (
+ select 1 from post_person_mention person
+ where person.post_id = post.post_id and person.person_id = $8))
+ and ($9::uuid is null or exists (
+ select 1 from post_product_mention product
+ where product.post_id = post.post_id and product.product_catalog_id = $9))
+ and ($10::text is null or exists (
+ select 1 from post_project_mention project
+ where project.post_id = post.post_id and project.project_key = $10))
+ and not (post.corporate_entity_id = any($11::uuid[]))
+ ), memberships as (
+ select assertion.post_id, assertion.assertion_status_code,
+ assertion.voice_concept_code
+ from post_voice_classification_assertion assertion
+ join eligible on eligible.post_id = assertion.post_id
+ where (assertion.valid_from is null or assertion.valid_from <= current_timestamp)
+ and (assertion.valid_to is null or assertion.valid_to > current_timestamp)
+ ), per_post as (
+ select eligible.post_id,
+ count(distinct memberships.voice_concept_code) as membership_count,
+ bool_or(memberships.assertion_status_code = 'source') as has_source,
+ bool_or(memberships.assertion_status_code = 'derived') as has_derived
+ from eligible left join memberships on memberships.post_id = eligible.post_id
+ group by eligible.post_id
+ ), conflicts as (
+ select post_id
+ from memberships
+ group by post_id
+ having bool_or(assertion_status_code = 'source')
+ and bool_or(assertion_status_code = 'derived')
+ and array_agg(distinct voice_concept_code order by voice_concept_code)
+ filter (where assertion_status_code = 'source')
+ is distinct from
+ array_agg(distinct voice_concept_code order by voice_concept_code)
+ filter (where assertion_status_code = 'derived')
+ ), categories as (
+ select voice_concept_code, count(distinct post_id) as post_count
+ from memberships group by voice_concept_code
+ )
+ select count(*) as total_eligible,
+ count(*) filter (where membership_count = 1) as classified_unique,
+ count(*) filter (where membership_count > 1) as multi_membership,
+ count(*) filter (where coalesce(has_source, false)) as source_count,
+ count(*) filter (where coalesce(has_derived, false)) as derived_count,
+ count(*) filter (where membership_count = 0) as unavailable,
+ (select count(*) from conflicts) as disagreement,
+ coalesce((select jsonb_object_agg(voice_concept_code, post_count)
+ from categories), '{{}}'::jsonb) as category_post_counts
+ from per_post
+ """,
+ list(authorized_corporate_entity_ids),
+ list(authorized_process_unit_ids),
+ date_from,
+ date_to,
+ corporate_entity_id,
+ process_unit_id,
+ team_id,
+ person_id,
+ product_catalog_id,
+ project_key,
+ list(excluded_corporate_entity_ids),
+ )
+ return dict(row)
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 2c7c4b6ce..a6e9b7aaa 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -204,6 +204,7 @@
"0215_operations_case_milestone.sql",
"0222_operations_case_analysis_input.sql",
"0228_product_semantic_catalog.sql",
+ "0230_voice_semantic_taxonomy.sql",
)
)
_LEFTOVER_MAP_AXIS_MIGRATION = (
@@ -417,9 +418,9 @@ def seeded_db(demo_analyst_token):
cur.execute(_LEFTOVER_MAP_COVERAGE_MIGRATION.read_text())
cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text())
cur.execute(_GLOBAL_ASK_SCOPE_MIGRATION.read_text())
+ cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text())
for migration_path in _PRODUCT_SEMANTIC_MIGRATIONS:
cur.execute(migration_path.read_text())
- cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text())
cur.execute(_CHANNEL_EVIDENCE_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text())
@@ -2011,6 +2012,254 @@ def test_post_detail_returns_authorized_product_evidence(
}]
+def test_voice_taxonomy_summary_uses_visible_post_denominator(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """Counts include visible unavailable posts and disclose overlap semantics."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute("delete from post_voice_classification_assertion")
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_sha256, "
+ "source_revision_digest) select post_id, 'voc', 'source', repeat('a', 64), "
+ "repeat('b', 64) from source_post"
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ response = client.get(
+ "/api/voice-taxonomy/summary",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200, response.text
+ payload = response.json()
+ assert payload["total_eligible"] == 4
+ assert payload["source_count"] == 4
+ assert payload["counts_overlap"] is True
+ assert payload["category_memberships"] == [{
+ "voice_concept_code": "voc",
+ "post_count": 4,
+ "eligible_percentage": 100.0,
+ }]
+ assert "category_post_counts" not in payload
+
+
+def test_voice_source_ingestion_is_available_for_future_business_event(
+ seeded_db,
+) -> None:
+ """Ingestion records a source label immediately, not at event time."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "delete from post_voice_classification_assertion where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "update source_post set post_body = post_body, "
+ "event_occurred_at = '2999-01-01T00:00:00Z' where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "select classification_assertion_id, valid_from "
+ "from post_voice_classification_assertion "
+ "where post_id = %s and assertion_status_code = 'source' "
+ "and voice_concept_code = 'voc'",
+ (seeded_db["public_post_id"],),
+ )
+ first_assertion_id, valid_from = cur.fetchone()
+ assert valid_from is None
+ cur.execute(
+ "update source_post set post_body = post_body || ' revised' "
+ "where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "update source_post set post_body = post_body where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "select count(*), count(*) filter (where valid_to is null), "
+ "count(*) filter (where classification_assertion_id = %s "
+ "and valid_to is not null), "
+ "max(supersedes_assertion_id::text) filter (where valid_to is null) "
+ "from post_voice_classification_assertion where post_id = %s "
+ "and assertion_status_code = 'source'",
+ (first_assertion_id, seeded_db["public_post_id"]),
+ )
+ assert cur.fetchone() == (
+ 2,
+ 1,
+ 1,
+ str(first_assertion_id),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def test_derived_voice_assertion_requires_model_receipt(seeded_db) -> None:
+ """A derived classification cannot persist without its model receipt."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur, pytest.raises(psycopg2.errors.CheckViolation):
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_span_start, "
+ "evidence_span_end, evidence_sha256, source_revision_digest) "
+ "values (%s, 'voc', 'derived', 0, 1, repeat('a', 64), repeat('b', 64))",
+ (seeded_db["public_post_id"],),
+ )
+ finally:
+ conn.close()
+
+
+def test_voice_source_reconcile_preserves_other_sourced_memberships(seeded_db) -> None:
+ """A body revision supersedes its source label without erasing another source."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_sha256, "
+ "source_revision_digest) values (%s, 'vom', 'source', repeat('a', 64), "
+ "repeat('b', 64))",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "update source_post set post_body = post_body || ' revised' where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "select voice_concept_code from post_voice_classification_assertion "
+ "where post_id = %s and assertion_status_code = 'source' "
+ "and valid_to is null order by voice_concept_code",
+ (seeded_db["public_post_id"],),
+ )
+ assert [row[0] for row in cur.fetchall()] == ["voc", "vom"]
+ finally:
+ conn.close()
+
+
+def test_voice_assertion_rejects_duplicate_open_scope(seeded_db) -> None:
+ """One post, status, and concept cannot have two current assertions."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute("delete from post_voice_classification_assertion")
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_sha256, "
+ "source_revision_digest) values (%s, 'voc', 'source', repeat('a', 64), "
+ "repeat('b', 64))",
+ (seeded_db["public_post_id"],),
+ )
+ with pytest.raises(psycopg2.errors.UniqueViolation):
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_sha256, "
+ "source_revision_digest) values (%s, 'voc', 'source', repeat('c', 64), "
+ "repeat('d', 64))",
+ (seeded_db["public_post_id"],),
+ )
+ finally:
+ conn.close()
+
+
+def test_voice_taxonomy_matching_multi_membership_is_not_a_disagreement(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """Matching source and derived concept sets remain agreement evidence."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute("delete from post_voice_classification_assertion")
+ for status_code in ("source", "derived"):
+ for concept_code in ("voc", "vom"):
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, "
+ "evidence_span_start, evidence_span_end, evidence_sha256, "
+ "source_revision_digest, orchestrator_model_receipt) "
+ "values (%s, %s, %s, %s, %s, repeat(%s, 64), repeat(%s, 64), %s)",
+ (
+ seeded_db["public_post_id"],
+ concept_code,
+ status_code,
+ 0 if status_code == "derived" else None,
+ 1 if status_code == "derived" else None,
+ "a" if concept_code == "voc" else "b",
+ "c" if concept_code == "voc" else "d",
+ "synthetic-receipt" if status_code == "derived" else None,
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ response = client.get(
+ "/api/voice-taxonomy/summary",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200, response.text
+ payload = response.json()
+ assert payload["multi_membership"] == 1
+ assert payload["disagreement"] == 0
+
+
+def test_voice_taxonomy_excludes_assertions_before_their_validity_window(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """A future assertion is unavailable until its recorded validity begins."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute("delete from post_voice_classification_assertion")
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, "
+ "evidence_sha256, source_revision_digest, valid_from) "
+ "values (%s, 'voc', 'source', repeat('a', 64), repeat('b', 64), "
+ "'2999-01-01T00:00:00Z')",
+ (seeded_db["public_post_id"],),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ response = client.get(
+ "/api/voice-taxonomy/summary",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200, response.text
+ payload = response.json()
+ assert payload["source_count"] == 0
+ assert payload["unavailable"] == payload["total_eligible"]
+
+
+def test_voice_taxonomy_summary_rejects_reversed_period(
+ client, demo_analyst_token
+) -> None:
+ response = client.get(
+ "/api/voice-taxonomy/summary?date_from=2026-02-01&date_to=2026-01-01",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 422
+ assert "Choose an end time" in response.json()["detail"]
+
+
+def test_voice_taxonomy_summary_accepts_one_calendar_day(
+ client, demo_analyst_token
+) -> None:
+ response = client.get(
+ "/api/voice-taxonomy/summary?date_from=2026-01-01&date_to=2026-01-01",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200
+
+
def test_post_detail_exposes_explicit_and_semantic_project_evidence(
client, demo_analyst_token, seeded_db
) -> None:
diff --git a/backend/tests/test_voice_taxonomy.py b/backend/tests/test_voice_taxonomy.py
new file mode 100644
index 000000000..0aae97afa
--- /dev/null
+++ b/backend/tests/test_voice_taxonomy.py
@@ -0,0 +1,126 @@
+"""Tests for authorized voice-taxonomy aggregate queries."""
+
+import asyncio
+
+from backend.app import main
+from backend.app.auth import CurrentAccount
+from backend.app.voice_taxonomy import load_voice_taxonomy_summary
+
+
+class _Connection:
+ def __init__(self) -> None:
+ self.args: tuple[object, ...] = ()
+
+ async def fetchrow(self, query: str, *args: object):
+ assert "post_product_mention" in query
+ assert "post_project_mention" in query
+ assert "post.visibility_code = 'public'" in query
+ assert "cardinality($2::uuid[]) = 0" in query
+ assert "not (post.corporate_entity_id = any($11::uuid[]))" in query
+ assert "source_deleted_flag" in query
+ self.args = args
+ return {
+ "total_eligible": 4,
+ "classified_unique": 1,
+ "multi_membership": 1,
+ "source_count": 2,
+ "derived_count": 1,
+ "unavailable": 2,
+ "disagreement": 1,
+ "category_post_counts": {"voc": 2, "vom": 1},
+ }
+
+
+def test_voice_summary_binds_authorization_and_every_filter() -> None:
+ connection = _Connection()
+ summary = asyncio.run(
+ load_voice_taxonomy_summary(
+ connection,
+ authorized_corporate_entity_ids=("corp-a",),
+ authorized_process_unit_ids=("pu-a",),
+ date_from="from",
+ date_to="to",
+ corporate_entity_id="corp-filter",
+ process_unit_id="pu-filter",
+ team_id="team-filter",
+ person_id="person-filter",
+ product_catalog_id="product-filter",
+ project_key="project-filter",
+ excluded_corporate_entity_ids=("demo-corp",),
+ )
+ )
+ assert summary["total_eligible"] == 4
+ assert connection.args == (
+ ["corp-a"], ["pu-a"], "from", "to", "corp-filter", "pu-filter",
+ "team-filter", "person-filter", "product-filter", "project-filter",
+ ["demo-corp"],
+ )
+
+
+def test_voice_summary_excludes_demo_entities_when_real_context_exists(monkeypatch) -> None:
+ """A real-data account never mixes synthetic seed rows into its denominator."""
+ captured: dict[str, object] = {}
+
+ class Acquire:
+ async def __aenter__(self):
+ return object()
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ class Pool:
+ def acquire(self) -> Acquire:
+ return Acquire()
+
+ async def has_real(_conn: object, entity_ids: list[str]) -> bool:
+ assert entity_ids == ["00000000-0000-0000-0000-000000000001"]
+ return True
+
+ async def demo_ids(_conn: object) -> set[str]:
+ return {"00000000-0000-0000-0000-000000000099"}
+
+ async def load(_conn: object, **kwargs: object) -> dict[str, object]:
+ captured.update(kwargs)
+ return {
+ "total_eligible": 0,
+ "classified_unique": 0,
+ "multi_membership": 0,
+ "source_count": 0,
+ "derived_count": 0,
+ "unavailable": 0,
+ "disagreement": 0,
+ "category_post_counts": {},
+ }
+
+ monkeypatch.setattr(main, "has_real_source_context", has_real)
+ monkeypatch.setattr(main, "fetch_demo_corporate_entity_ids", demo_ids)
+ monkeypatch.setattr(main, "load_voice_taxonomy_summary", load)
+ account = CurrentAccount(
+ user_account_id="00000000-0000-0000-0000-000000000010",
+ external_subject_id="synthetic-subject",
+ display_name="Synthetic reader",
+ preferred_locale="en",
+ corporate_entity_ids=frozenset({"00000000-0000-0000-0000-000000000001"}),
+ process_unit_ids=frozenset(),
+ permission_codes=frozenset({"post_read"}),
+ )
+
+ result = asyncio.run(
+ main.read_voice_taxonomy_summary(
+ date_from=None,
+ date_to=None,
+ corporate_entity_id=None,
+ process_unit_id=None,
+ team_id=None,
+ person_id=None,
+ product_catalog_id=None,
+ project_key=None,
+ account=account,
+ pool=Pool(),
+ )
+ )
+
+ assert result["total_eligible"] == 0
+ assert captured["excluded_corporate_entity_ids"] == (
+ "00000000-0000-0000-0000-000000000099",
+ )
diff --git a/docs/adr/0230-source-preserving-voice-semantic-taxonomy.md b/docs/adr/0230-source-preserving-voice-semantic-taxonomy.md
new file mode 100644
index 000000000..9ad1bca8e
--- /dev/null
+++ b/docs/adr/0230-source-preserving-voice-semantic-taxonomy.md
@@ -0,0 +1,63 @@
+# ADR 0230: Source-preserving voice semantic taxonomy
+
+- Status: Accepted
+- Date: 2026-08-26
+
+## Context
+
+The imported `source_post.voc_type_code` is provenance, not permission to
+overwrite the source or collapse organization relationships into one post
+label. Two vocabularies already exist: post types `voc`, `vocc`, `voco`, `vom`,
+and `vop`; and post-scoped organization relationships `rel_voc`, `rel_vocc`,
+`rel_voco`, `rel_vom`, `rel_vop`, and `rel_vos`. Internal `rel_vos` means Voice
+of Supplier. It is not ISO 16355's Voice of Stakeholder and is not in the post
+type scheme.
+
+## Decision
+
+Source assertions and contextual-orchestrator-derived assertions are append-only
+and separate. Derived assertions require an exact source span, source revision
+digest, evidence digest, model receipt, and optional validity interval. A post
+or organization may have multiple simultaneous memberships. Conflicting
+source and derived concept sets remain disagreement evidence; matching
+multi-membership sets are agreement, not a pairwise mismatch. A summary admits
+an assertion only while its optional validity interval contains the query
+instant. Imported source labels have no business-event validity interval: they
+are available as provenance as soon as recorded, even when the post describes
+a future event. Optional validity intervals describe derived or explicitly
+time-scoped relationship claims, not ingestion availability. No threshold,
+weight, keyword, alias rule, or forced winner is permitted. A replacement or
+retraction names the superseded assertion and closes validity with provenance.
+The database reconciles the source assertion in the same transaction that
+inserts or changes `source_post.voc_type_code` or its revision-bearing body.
+It retains the prior assertion as a closed, superseded version; migration
+replay is a recovery/backfill path, not the normal ingestion lifecycle.
+
+Counts use the same authorized eligible-post denominator at the same cutoff and
+filters. They report source, derived, multi-membership, disagreement, and
+unavailable counts. Per-category membership percentages divide by all eligible
+posts and disclose that overlapping category counts may exceed the denominator.
+Organization-relationship counts use a separately named evidence-bearing
+post-by-organization denominator. Filters may narrow period, corporate entity,
+PU, team, person, product, or project without changing these denominators.
+
+SHACL excludes `rel_vos` from the post scheme, admits it only in the organization
+relationship scheme, and requires derived evidence/digest/receipt/time fields.
+Raw `source_post.voc_type_code` is never updated by this projection.
+
+## Consequences
+
+- Operators can compare original and derived semantics without losing either.
+- Category totals are intentionally non-additive under multi-membership.
+- A missing orchestrator result remains unavailable, never a negative class.
+- Product-scoped supplier/customer transitions can coexist across intervals.
+
+## References
+
+International Organization for Standardization. (2017). *ISO 16355-4:2017:
+Applications of statistical and related methods to new technology and product
+development process—Part 4: Analysis of non-quantitative and quantitative Voice
+of Customer and Voice of Stakeholder*. https://www.iso.org/standard/62607.html
+
+Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*.
+World Wide Web Consortium. https://www.w3.org/TR/prov-dm/
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 6a85c77e5..5a3b745d0 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -28,6 +28,7 @@ decision from them.
| macOS-native Rust/MLX mathematical compute boundary | [0226](0226-macos-native-mlx-mathematical-compute-boundary.md), [0208](0208-externalize-local-mathematical-compute.md) |
| Observed PostgreSQL WAL/checkpoint tuning plan | [0227](0227-observed-postgresql-runtime-tuning.md) |
| Product semantic catalog and typed evidence relations | [0228](0228-evidence-bound-product-semantic-catalog.md) |
+| Source-preserving voice semantic taxonomy | [0230](0230-source-preserving-voice-semantic-taxonomy.md) |
[0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/).
diff --git a/docs/doctoring/python-mathematical-compute-boundary-audit.md b/docs/doctoring/python-mathematical-compute-boundary-audit.md
index ed0a56d5e..7bb086cc6 100644
--- a/docs/doctoring/python-mathematical-compute-boundary-audit.md
+++ b/docs/doctoring/python-mathematical-compute-boundary-audit.md
@@ -27,8 +27,10 @@ does not relabel still-local Python paths as Rust/GPU compliant.
| `lineageweave/leftover_pairs.py` | **migrated:** identifier projection and closest/farthest selection only | fast-mlsirm | protected-main Rust `residual_interaction_map` with residual, coverage, SVD/Gabriel coordinates, distances, reconstruction and shares | `period_report.py`, report ingestion/seed; owner contract and consumer projection tests |
| `backend/app/post_chat_ingestion.py` | active Global Ask cosine, vector norm, maximum semantic score; the unused `embedding_client.py` cosine/max-pooling experiment is deleted | RankWeave or another accepted Rust retrieval-score owner | versioned ranked-evidence envelope over ABAC-visible semantic units; fail closed until accepted | Global Ask retrieval and post-chat tests |
| `lineageweave/knowledge_graph.py` | random walk with restart, convergence delta, adaptive relevance cutoff | RankWeave graph-ranking contract | ranked-node artifact with contribution and convergence evidence | related-person/entity API paths; knowledge-graph tests |
+| `lineageweave/channels.py` | local time-decay score and `SequenceMatcher` text similarity fallback | RankWeave similarity contract; TEPP supplies temporal evidence | owner-computed, provenance-bearing channel evidence | `reconstruct.py`; channel and reconstruction tests |
| `lineageweave/reconstruct.py` | channel-weight renormalization, candidate-score fusion and minimum-score decision | RankWeave fusion; TEPP supplies independent lineage criterion | accepted edge-ranking artifact; LineageWeave persists selected edge and channel provenance | lineage rebuild/start/seed/server; reconstruct, persistence, API tests |
| `lineageweave/rankweave_client.py` | channel construction and token overlap remain; **owner-bound:** classic/weighted RRF and contribution arithmetic now come from RankWeave #47, whose Python core still awaits the required Rust CPU/GPU migration | RankWeave | Rust-backed strict ranking artifact exposing owner-computed contributions and owned channel construction | `/api/rankings`, frontend Rankings; `tests/test_rankweave_client.py` and frontend tests |
+| `lineageweave/corporate_hierarchy_resolution.py` | `SequenceMatcher` organization-name similarity, score threshold, and top-score selection | external entity-resolution owner contract required | unique/miss/tie catalog-resolution artifact with evidence and policy version | organization resolution ingestion; corporate-hierarchy and API tests |
`lineageweave/post_evaluation.py` imports fast-mlsirm only for its published
judge contract and `to_irt_row` projection. It performs no fitted numerical
@@ -65,3 +67,36 @@ labels do not replace foreign keys. Dashboard and post detail endpoints read
only accepted persisted rows and preserve source-post ABAC. Storybook covers
accepted, pending, failed, stale-digest, non-converged, hidden-evidence, and
multiple-membership cases before UI activation.
+
+## 2026-08-26 stacked-PR audit
+
+The exact reviewed heads were PR #692 `583059edcffe994b18a6fbf3cb3b00bf4647c2a3`,
+PR #693 `999063d22e60469227eeea308fee787683952cab`, and PR #694
+`296cbae6c9ac2839b0f5ff150ae02ebf4f726627`. The review used CodeGraph before
+diff inspection.
+
+- PR #692 adds evidence-span normalization, unique/miss/tie catalog binding,
+ persistence, and projection. It adds no statistical score, vector algebra,
+ fitted weight, or local model.
+- PR #693's Python code validates vector shape and finiteness, serializes the
+ exact UTF-8 request body, and chooses a prefix under an upstream-advertised
+ byte ceiling. Those are transport and schema-validation operations allowed
+ by ADR 0208, not token estimation or vector scoring. Tokenization, token
+ ranges, provider-limit packing, checked token totals, and shard construction
+ are owned by contextual-orchestrator's Rust/PyO3 extension pinned by the
+ Docker build. The owner follow-up PR #865 is stacked on the current owning
+ #857 branch and fails closed at an undecodable
+ token ceiling and preserves complete UTF-8 scalars when a nominal token
+ boundary divides their byte representation.
+- PR #694 delegates overlap counts and the shared eligible denominator to one
+ authorization-filtered SQL aggregate. Converting those returned counts to a
+ displayed percentage is presentation formatting, explicitly outside the
+ model-ownership inventory. It supplies no threshold, category weight,
+ probability model, or forced winner.
+
+No new Python mathematical or psychometric implementation was found in this
+stack. The highest-leverage newly exercised owner path is therefore the Rust
+token packer rather than a duplicate LineageWeave implementation. Existing
+time-decay and string similarity, cosine, graph-ranking, fusion, period-report,
+and anchored channel-weight debt remains frozen under the owner and acceptance
+criteria above; this audit does not reclassify it as complete.
diff --git a/docs/ontology/lineageweave-kg-shapes.ttl b/docs/ontology/lineageweave-kg-shapes.ttl
index 13cf595e1..057b7dec9 100644
--- a/docs/ontology/lineageweave-kg-shapes.ttl
+++ b/docs/ontology/lineageweave-kg-shapes.ttl
@@ -210,6 +210,65 @@
sh:class :Post ;
] .
+:PostVoiceClassificationAssertionShape a sh:NodeShape ;
+ sh:targetClass :PostVoiceClassificationAssertion ;
+ sh:property [
+ sh:path :voiceConceptCode ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in ("voc" "vocc" "voco" "vom" "vop") ;
+ ] ;
+ sh:property [
+ sh:path :voiceAssertionStatus ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in ("source" "derived") ;
+ ] ;
+ sh:property [
+ sh:path :voiceEvidenceDigest ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [
+ sh:path :sourceRevisionDigest ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :Post ;
+ ] ;
+ sh:property [ sh:path :validFrom ; sh:maxCount 1 ; sh:datatype xsd:dateTime ; sh:lessThanOrEquals :validTo ] ;
+ sh:property [ sh:path :validTo ; sh:maxCount 1 ; sh:datatype xsd:dateTime ] ;
+ sh:or (
+ [ sh:property [ sh:path :voiceAssertionStatus ; sh:hasValue "source" ] ]
+ [
+ sh:property [ sh:path :voiceAssertionStatus ; sh:hasValue "derived" ] ;
+ sh:property [ sh:path :orchestratorModelReceipt ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:string ; sh:minLength 1 ] ;
+ sh:property [ sh:path :evidenceSpanStart ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ; sh:minInclusive 0 ; sh:lessThan :evidenceSpanEnd ] ;
+ sh:property [ sh:path :evidenceSpanEnd ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ]
+ ]
+ ) .
+
+:OrganizationVoiceRelationshipAssertionShape a sh:NodeShape ;
+ sh:targetClass :OrganizationVoiceRelationshipAssertion ;
+ sh:property [
+ sh:path :voiceConceptCode ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in ("rel_voc" "rel_vocc" "rel_voco" "rel_vom" "rel_vop" "rel_vos") ;
+ ] ;
+ sh:property [
+ sh:path :orchestratorModelReceipt ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:minLength 1 ;
+ ] ;
+ sh:property [
+ sh:path :voiceEvidenceDigest ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [
+ sh:path :sourceRevisionDigest ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [ sh:path :evidenceSpanStart ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ; sh:minInclusive 0 ; sh:lessThan :evidenceSpanEnd ] ;
+ sh:property [ sh:path :evidenceSpanEnd ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ] ;
+ sh:property [ sh:path :validFrom ; sh:maxCount 1 ; sh:datatype xsd:dateTime ; sh:lessThanOrEquals :validTo ] ;
+ sh:property [ sh:path :validTo ; sh:maxCount 1 ; sh:datatype xsd:dateTime ] ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :Post ;
+ ] .
+
:OurSidePersonShape a sh:NodeShape ;
rdfs:label "Our-side person shape" ;
sh:comment "Closed-world complement of :OurSidePerson owl:disjointWith :CounterpartyPerson: an instance of one can never be typed as the other." ;
diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl
index f59b50650..322a868b0 100644
--- a/docs/ontology/lineageweave-kg.ttl
+++ b/docs/ontology/lineageweave-kg.ttl
@@ -487,6 +487,39 @@
:evidenceInputDigest a owl:DatatypeProperty ;
rdfs:domain :ProductMention ; rdfs:range xsd:string .
+:PostVoiceClassificationAssertion a owl:Class ;
+ rdfs:label "Post voice classification assertion"@en .
+
+:OrganizationVoiceRelationshipAssertion a owl:Class ;
+ rdfs:label "Organization voice relationship assertion"@en .
+
+:voiceConceptCode a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:voiceAssertionStatus a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:voiceEvidenceDigest a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:sourceRevisionDigest a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:evidenceSpanStart a owl:DatatypeProperty ;
+ rdfs:range xsd:integer .
+
+:evidenceSpanEnd a owl:DatatypeProperty ;
+ rdfs:range xsd:integer .
+
+:validFrom a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime .
+
+:validTo a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime .
+
+:orchestratorModelReceipt a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
:Order a owl:Class ; rdfs:label "Order"@en .
:SalesContext a owl:Class ; rdfs:label "Sales context"@en .
:BusinessManagementContext a owl:Class ; rdfs:label "Business-management context"@en .
diff --git a/docs/product-requirements.md b/docs/product-requirements.md
index eb39a775f..1d9795cff 100644
--- a/docs/product-requirements.md
+++ b/docs/product-requirements.md
@@ -198,6 +198,13 @@ derive identity from keywords, tags, weak source sentinels, or arbitrary
similarity thresholds. Historical processing is bounded, asynchronous,
digest-idempotent, and authorization-filtered when read.
+The source post voice scheme (`voc`, `vocc`, `voco`, `vom`, `vop`) and
+post-scoped organization relationship scheme (the same five relationships plus
+supplier `rel_vos`) remain distinct. Source and derived assertions coexist;
+multi-membership and disagreements are reported without forced selection.
+Authorized counts use the same period and organization/PU/team/person/product/
+project filters and disclose overlapping category totals.
+
## 7. Traceability
- Product/data boundary: ADR 0001, ADR 0089.
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 61bbb8ccb..b2e3f6c8a 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -456,13 +456,14 @@ this file per §3.5 of the prior snapshot).
| Semantic source rendering | ADR 0223 and migration 0221 give new paragraph, list, table, MathML formula, and caller-parsed conversation-turn units explicit persisted kinds without rewriting historical rows; image regions remain ordered normalized children under ADR 0091. This branch is candidate evidence, not protected-main delivery | Land the exact-head candidate, then prove an authorized semantic-only query retrieves each persisted unit kind and gather authenticated browser evidence that nesting, continuation alignment, formula units, and image regions retain source order |
| Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence |
| Product semantic identity | ADR 0228 and migration 0228 define normalized product group/model/variant/trade-item identities, scoped GTIN/MPN keys, exact-span provenance, fail-closed unique/tie/missing/unavailable resolution, and foreign-key relations to existing project and operations facts. The worker candidate reuses the durable post-content queue and skips an unchanged authorized input digest. No authorized-corpus product counts or rendered acceptance evidence are recorded | Land the stack, add authorization-filtered Post/Dashboard relationship reads and SHACL projection, then verify aggregate-only backfill outcomes plus desktop/mobile Storybook screenshots without exposing identifying runtime rows |
+| Voice semantic taxonomy | ADR/migration 0230 preserve the five-value source post scheme separately from the six-value post-scoped organization relationship scheme, retain source/derived disagreement and multi-membership, and provide authorized overlap-aware aggregate filters. Candidate Storybook evidence is synthetic; no private-corpus derived assertion count is recorded | Land the stack, run bounded orchestrator backfill, and verify aggregate-only source/derived/disagreement/unavailable counts at one declared cutoff without exposing record identities |
| Knowledge Graph readability | The black evidence-node root cause is an undefined-token fallback; the design-token repair and long-label/evidence-table coverage remain only on closed, unmerged #490, not protected `main` | Recreate the token repair on a current base and deliver it through protected `main`, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface |
| Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding |
| Calendar / Naruon | #355 delivered the projection contract; v2.17.0 wires operator consumption without forwarding the end-user token. Naruon producer, provider/consumer fixtures, and protected merge remain open (#336) | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired |
| SKOS organization aliases | Catalog binding and chip caption live on #480 / #482 | One catalog row per corroborated org; companion caption is hint-only until bound |
| Event Lineage evidence | Channel evidence and Allen relations live on #387 / #484 | Persist channel scores, explain them in the popup, never invent a fused score |
| Scientific measurement | Durable accepted TEPP receipts and LineageWeave #614's exact accepted snapshot/cutoff/run/pair-count consumer are protected; TEPP #237 remains open, so no registered producer artifact exists yet. #387 removes inferred/default persistence weights, but several older reconstruction tests still pass hand-authored numeric dictionaries that are not estimator evidence | Land TEPP #237 through its protected gate, then replace remaining reconstruction-test constants with provenance-bearing fast-mlsirm estimates over synthetic fixtures. Retain true-parameter RMSE recovery as the acceptance bar |
-| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stacked slice also deletes production-unused Python cosine/max-pooling helpers and freezes the one active direct-vector path with an AST inventory. Global Ask cosine remains Python migration debt because no accepted versioned Rust retrieval-scoring contract exists; #693 embedding backfill performs only exact request-envelope sizing, advertised-ceiling validation, vector-shape validation, and persistence, not token estimation or vector algebra. RankWeave #47 remains Python and is not the final Rust CPU/GPU execution contract. | Land a versioned Rust owner envelope for ABAC-visible semantic-unit scoring with model/version, input digest, deterministic CPU/GPU parity, finite dimensions, ranked evidence, and failure status. Then switch Global Ask to strict envelope validation, prove authorized semantic retrieval and zero-provider fail-closed behavior, and delete `KNOWN_LOCAL_DIRECT_VECTOR_ARITHMETIC`. Separately land the domain-neutral anchored-weight contract before deleting frozen channel-weight code. |
+| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stack also removes local RRF contribution arithmetic and corrects the invalid all-ones call to RankWeave's convex API through RankWeave #47 (`f92c3a2c`), but RankWeave #47 remains Python and is not the final Rust CPU/GPU execution contract. The backend pins fast-mlsirm protected-main `09f762ded35786dd1078222a4577ff09d649816f`; TEPP-specific fast-mlsirm PR #1423 closed unmerged and is not a valid owner contract. The stacked deletion slice removes production-unused Python cosine/max-pooling helpers and freezes the one active direct-vector path with an AST inventory. Global Ask cosine remains Python migration debt because no accepted versioned Rust retrieval-scoring contract exists; embedding backfill performs only exact request-envelope sizing, advertised-ceiling validation, vector-shape validation, and persistence, not token estimation, model selection, or vector algebra. contextual-orchestrator owns automatic model discovery, selection, tokenization, packing, and provider execution. The doctoring inventory still names period calibration, channel weighting, time-decay and string similarity, cosine, graph ranking, and fusion debt. | Land the contextual-orchestrator owner changes through their protected gates and advance LineageWeave's immutable pin only to a protected owner commit. Land a versioned Rust RankWeave envelope for authorization-visible semantic-unit scoring with model/version provenance, input digest, deterministic CPU/GPU parity, finite dimensions, ranked evidence, and explicit failure. Then switch Global Ask to strict envelope validation, prove authorized semantic retrieval and zero-provider fail-closed behavior, and delete `KNOWN_LOCAL_DIRECT_VECTOR_ARITHMETIC`. Separately land the domain-neutral anchored-weight contract before deleting frozen channel-weight code. |
| Asynchronous authorization | Protected `main` rebuilds Global Ask worker scope after the bearer token leaves the request; #468 now persists exact Keyverse organization/process-unit scope in 3NF child tables and intersects it with current affiliations | Land #468 through the protected gate; prove a second affiliation and a revoked process unit cannot widen delayed-job evidence |
| Planned-facility intent | Planned-facility relationship intent remains only on closed, unmerged #490; earlier stack-only merges were not protected delivery | Recreate the evidence-backed slice on a current base and land through protected `main` before a release claim |
| Accessibility and responsive UX | #602 delivered base post-detail modal semantics; #605 adds selected-post refocus, collapsed/hidden/inert/CSS-invisible focus exclusion across both modal types, readable evidence separators, focused tests, and desktop/mobile Storybook screenshots | Land #605 through the protected gate, then complete screen-reader and authenticated Playwright acceptance on the exact release head |
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index 0ab070b07..8a0d4d015 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -5,7 +5,7 @@ operator-facing control you can click before changing product CSS.
| Story | Operator next action | Token / module |
|---|---|---|
-| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, repeat issue, or topic-context influence. `TopicInfluenceAccepted` preserves exact ties, multiple membership, time states, uncertainty, and source actions; `EvidenceReady` shows the producer-contract unavailable state. `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover mobile, scoped-empty, explicit evidence-absence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard`, `TopicContextInfluence` |
+| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, repeat issue, or topic-context influence. `TopicInfluenceAccepted` preserves exact ties, multiple membership, time states, uncertainty, and source actions; `EvidenceReady` shows the producer-contract unavailable state. `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, `ConcurrentLoading`, `LoadError`, and `VoiceSummaryLoadError` cover mobile, scoped-empty, explicit evidence-absence, analysis-pending, retryable failure, one accessible announcement for parallel loading, whole-dashboard transport failure, and independently retryable voice-summary failure. | `--color-dashboard-*`, `OperationsDashboard`, `TopicContextInfluence` |
| `Ask Agent/AnswerEvidenceTimeline` | Select an answer citation to focus its event card, select the card to return to the answer, then open its evidence or source post. `MissingObservedTime` keeps an absent event clock explicit and `NarrowViewport` verifies the single-column interaction. | `--color-accent-*`, `--radius-panel`, `--size-control-min`, `AskAnswerTimeline` |
| `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` |
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
@@ -18,6 +18,7 @@ operator-facing control you can click before changing product CSS.
| `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` |
| `Evidence/OntologyExplorer` | Distinguish Post, Person, Organization, and Team by shape and text, use the token-backed surface as a secondary cue, then open the exact-value table or cited evidence. Compare desktop, narrow, drawer, empty, truncated, denied, stale, and rejected states. | `--ontology-node-*-fill`, `OntologyExplorer` |
| `Post/ProductEvidenceList` | Open the cited product span. If the identity is unresolved, review the product catalog before using the relationship. Compare catalog-linked and catalog-review-required states. | `--surface`, `--border`, `ProductEvidenceList` |
+| `Dashboard/VoiceTaxonomySummary` | Compare source and semantic classifications, note overlapping memberships, then review disagreements and records waiting for evidence. | `--surface`, `--border`, `VoiceTaxonomySummary` |
Repeated web objects must use `frontend/src/styles/tokens.css` and a module
under `frontend/src/components/`. Do not add a second Node package manager;
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index b1119304c..a7717f8ff 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -122,6 +122,34 @@ export interface OperationsDashboardResponse {
cases: OperationsDashboardCase[];
}
+export interface VoiceTaxonomySummary {
+ total_eligible: number;
+ classified_unique: number;
+ multi_membership: number;
+ source_count: number;
+ derived_count: number;
+ unavailable: number;
+ disagreement: number;
+ counts_overlap: boolean;
+ category_memberships: Array<{
+ voice_concept_code: "voc" | "vocc" | "voco" | "vom" | "vop";
+ post_count: number;
+ eligible_percentage: number;
+ }>;
+}
+
+export async function fetchVoiceTaxonomySummary(
+ accessToken: string,
+ dateFrom = "",
+ dateTo = "",
+): Promise {
+ const query = new URLSearchParams();
+ if (dateFrom) query.set("date_from", dateFrom);
+ if (dateTo) query.set("date_to", dateTo);
+ const suffix = query.size ? `?${query.toString()}` : "";
+ return backendFetch(`/api/voice-taxonomy/summary${suffix}`, accessToken);
+}
+
export interface TopicContextDashboard {
status_code: "accepted" | "unavailable" | "not_applicable";
reason_code: string | null;
diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx
index 665b716f0..fe130639d 100644
--- a/frontend/src/components/OperationsDashboard.stories.tsx
+++ b/frontend/src/components/OperationsDashboard.stories.tsx
@@ -165,3 +165,41 @@ export const LoadError: Story = {
await expect(canvas.getByRole("button", { name: "다시 시도" })).toBeVisible();
},
};
+
+export const ConcurrentLoading: Story = {
+ args: EvidenceReady.args,
+ render: () => undefined} />,
+ beforeEach: () => {
+ const fetchBeforeStory = globalThis.fetch;
+ globalThis.fetch = async () => new Promise(() => undefined);
+ return () => { globalThis.fetch = fetchBeforeStory; };
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getAllByRole("status")).toHaveLength(1);
+ await expect(canvas.getByRole("status")).toHaveTextContent("Loading voice evidence");
+ },
+};
+
+export const VoiceSummaryLoadError: Story = {
+ args: EvidenceReady.args,
+ render: () => undefined} />,
+ beforeEach: () => {
+ const fetchBeforeStory = globalThis.fetch;
+ globalThis.fetch = async (input) => {
+ if (String(input).includes("/api/dashboard")) {
+ return new Response(JSON.stringify(EvidenceReady.args!.data!), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ throw new Error("synthetic voice-summary transport failure");
+ };
+ return () => { globalThis.fetch = fetchBeforeStory; };
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.findByRole("alert")).resolves.toHaveTextContent("Voice evidence could not be loaded");
+ await expect(canvas.getByRole("button", { name: "Retry voice evidence" })).toBeVisible();
+ },
+};
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index df65027b3..35820d4b7 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -1,14 +1,23 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-import { fetchOperationsDashboard, type OperationsDashboardResponse } from "../api";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { fetchOperationsDashboard, fetchVoiceTaxonomySummary, type OperationsDashboardResponse } from "../api";
import { OperationsDashboard, OperationsDashboardView } from "./OperationsDashboard";
vi.mock("../api", async (importOriginal) => ({
...(await importOriginal()),
fetchOperationsDashboard: vi.fn(),
+ fetchVoiceTaxonomySummary: vi.fn(),
}));
+beforeEach(() => {
+ vi.mocked(fetchVoiceTaxonomySummary).mockReset().mockResolvedValue({
+ total_eligible: 0, classified_unique: 0, multi_membership: 0,
+ source_count: 0, derived_count: 0, unavailable: 0, disagreement: 0,
+ counts_overlap: true, category_memberships: [],
+ });
+});
+
const data: OperationsDashboardResponse = {
period_label: "2026-08-01–2026-08-25 · Event time",
total_post_count: 20,
@@ -179,6 +188,11 @@ describe("OperationsDashboardView", () => {
});
it("keeps period controls mounted while a changed period loads", async () => {
+ vi.mocked(fetchVoiceTaxonomySummary).mockResolvedValue({
+ total_eligible: 0, classified_unique: 0, multi_membership: 0,
+ source_count: 0, derived_count: 0, unavailable: 0, disagreement: 0,
+ counts_overlap: true, category_memberships: [],
+ });
vi.mocked(fetchOperationsDashboard)
.mockResolvedValueOnce(data)
.mockImplementationOnce(() => new Promise(() => undefined));
@@ -191,9 +205,55 @@ describe("OperationsDashboardView", () => {
});
it("requests the external scope at the API boundary", async () => {
- vi.mocked(fetchOperationsDashboard).mockResolvedValue(data);
+ vi.mocked(fetchVoiceTaxonomySummary).mockClear();
+ vi.mocked(fetchOperationsDashboard).mockReset().mockResolvedValue(data);
render( undefined} />);
await screen.findByText("5건");
expect(fetchOperationsDashboard).toHaveBeenCalledWith("synthetic-token", "", "", true);
+ expect(fetchVoiceTaxonomySummary).not.toHaveBeenCalled();
+ });
+
+ it("shows a failed voice summary and retries only that evidence", async () => {
+ vi.mocked(fetchOperationsDashboard).mockReset().mockResolvedValue(data);
+ vi.mocked(fetchVoiceTaxonomySummary)
+ .mockReset()
+ .mockRejectedValueOnce(new Error("synthetic transport failure"))
+ .mockResolvedValueOnce({
+ total_eligible: 0, classified_unique: 0, multi_membership: 0,
+ source_count: 0, derived_count: 0, unavailable: 0, disagreement: 0,
+ counts_overlap: true, category_memberships: [],
+ });
+ render( undefined} />);
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Voice evidence could not be loaded.");
+ await userEvent.click(screen.getByRole("button", { name: "Retry voice evidence" }));
+ expect(await screen.findByRole("heading", { name: "Voice evidence overview" })).toBeInTheDocument();
+ expect(fetchVoiceTaxonomySummary).toHaveBeenCalledTimes(2);
+ expect(fetchOperationsDashboard).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps voice evidence actionable when the dashboard request fails", async () => {
+ vi.mocked(fetchOperationsDashboard).mockReset().mockRejectedValue(new Error("synthetic dashboard failure"));
+ vi.mocked(fetchVoiceTaxonomySummary).mockReset().mockResolvedValue({
+ total_eligible: 0, classified_unique: 0, multi_membership: 0,
+ source_count: 0, derived_count: 0, unavailable: 0, disagreement: 0,
+ counts_overlap: true, category_memberships: [],
+ });
+ render( undefined} />);
+
+ expect(await screen.findByText("Dashboard 근거를 불러오지 못했습니다.")).toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: "Voice evidence overview" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "다시 시도" })).toBeInTheDocument();
+ });
+});
+
+describe("OperationsDashboard", () => {
+ it("announces concurrent dashboard and voice loading through one status region", () => {
+ vi.mocked(fetchOperationsDashboard).mockImplementation(() => new Promise(() => undefined));
+ vi.mocked(fetchVoiceTaxonomySummary).mockImplementation(() => new Promise(() => undefined));
+ render( undefined} />);
+ expect(screen.getAllByRole("status")).toHaveLength(1);
+ expect(screen.getByRole("status")).toHaveTextContent("Dashboard 근거를 불러오는 중입니다.");
+ expect(screen.getByRole("status")).toHaveTextContent("Loading voice evidence...");
});
});
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index 2b699505d..dbd8bfb26 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
-import { fetchOperationsDashboard, type OperationsDashboardResponse } from "../api";
+import { fetchOperationsDashboard, fetchVoiceTaxonomySummary, type OperationsDashboardResponse, type VoiceTaxonomySummary as VoiceSummary } from "../api";
+import { t } from "../i18n";
+import { VoiceTaxonomySummary } from "./VoiceTaxonomySummary";
function formatElapsed(seconds: number): string {
const days = Math.floor(seconds / 86_400);
@@ -35,6 +37,9 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP
const [periodEnd, setPeriodEnd] = useState("");
const [submittedPeriod, setSubmittedPeriod] = useState<[string, string]>(["", ""]);
const [retryCount, setRetryCount] = useState(0);
+ const [voiceSummary, setVoiceSummary] = useState(null);
+ const [voiceSummaryError, setVoiceSummaryError] = useState(false);
+ const [voiceRetryCount, setVoiceRetryCount] = useState(0);
useEffect(() => {
let active = true;
@@ -46,6 +51,17 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP
return () => { active = false; };
}, [accessToken, externalOnly, submittedPeriod, retryCount]);
+ useEffect(() => {
+ let active = true;
+ setVoiceSummary(null);
+ setVoiceSummaryError(false);
+ if (externalOnly) return () => { active = false; };
+ fetchVoiceTaxonomySummary(accessToken, ...submittedPeriod)
+ .then((value) => active && setVoiceSummary(value))
+ .catch(() => active && setVoiceSummaryError(true));
+ return () => { active = false; };
+ }, [accessToken, externalOnly, submittedPeriod, voiceRetryCount]);
+
return <>
{
event.preventDefault();
@@ -63,9 +79,21 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP
) : data ? (
- ) : (
- Dashboard 근거를 불러오는 중입니다.
- )}
+ ) : null}
+ {!externalOnly && voiceSummary ? : null}
+ {!externalOnly && voiceSummaryError ? (
+
+ {t("Voice evidence overview")}
+ {t("Voice evidence could not be loaded.")}
+ setVoiceRetryCount((count) => count + 1)}>{t("Retry voice evidence")}
+
+ ) : null}
+ {(!data && !error) || (!externalOnly && !voiceSummary && !voiceSummaryError) ? (
+
+ {!data && !error ?
Dashboard 근거를 불러오는 중입니다.
: null}
+ {!externalOnly && !voiceSummary && !voiceSummaryError ?
{t("Loading voice evidence...")}
: null}
+
+ ) : null}
>;
}
diff --git a/frontend/src/components/VoiceTaxonomySummary.stories.tsx b/frontend/src/components/VoiceTaxonomySummary.stories.tsx
new file mode 100644
index 000000000..8665a6f6b
--- /dev/null
+++ b/frontend/src/components/VoiceTaxonomySummary.stories.tsx
@@ -0,0 +1,16 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { VoiceTaxonomySummary } from "./VoiceTaxonomySummary";
+
+const meta = { title: "Dashboard/VoiceTaxonomySummary", component: VoiceTaxonomySummary } satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const OverlappingEvidence: Story = { args: { data: {
+ total_eligible: 12, classified_unique: 5, multi_membership: 2,
+ source_count: 6, derived_count: 7, unavailable: 3, disagreement: 1,
+ counts_overlap: true,
+ category_memberships: [
+ { voice_concept_code: "voc", post_count: 5, eligible_percentage: 41.7 },
+ { voice_concept_code: "vom", post_count: 4, eligible_percentage: 33.3 },
+ ],
+} } };
diff --git a/frontend/src/components/VoiceTaxonomySummary.test.tsx b/frontend/src/components/VoiceTaxonomySummary.test.tsx
new file mode 100644
index 000000000..e46a3925b
--- /dev/null
+++ b/frontend/src/components/VoiceTaxonomySummary.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { VoiceTaxonomySummary } from "./VoiceTaxonomySummary";
+
+describe("VoiceTaxonomySummary", () => {
+ it("discloses overlapping counts and the next review action", () => {
+ render( );
+ expect(screen.getByRole("heading", { name: "Voice evidence overview" })).toBeInTheDocument();
+ expect(screen.getByText("Records in multiple voice categories")).toBeInTheDocument();
+ expect(screen.getByText("Records without voice evidence")).toBeInTheDocument();
+ expect(screen.getByText(/voice categories, so category counts can overlap/)).toBeInTheDocument();
+ expect(screen.getByText(/Review disagreements and records without voice evidence/)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/VoiceTaxonomySummary.tsx b/frontend/src/components/VoiceTaxonomySummary.tsx
new file mode 100644
index 000000000..adb067bad
--- /dev/null
+++ b/frontend/src/components/VoiceTaxonomySummary.tsx
@@ -0,0 +1,36 @@
+import type { VoiceTaxonomySummary as Summary } from "../api";
+import { t, tf } from "../i18n";
+
+const voiceLabels = {
+ voc: "Voice of Customer",
+ vocc: "Voice of Customer's customer",
+ voco: "Voice of Competitor",
+ vom: "Voice of Market",
+ vop: "Voice of Partner",
+} as const;
+
+export function VoiceTaxonomySummary({ data }: { data: Summary }) {
+ return (
+
+ {t("Voice evidence overview")}
+ {tf("Compare voice classifications across {count} visible records.", { count: data.total_eligible.toLocaleString() })}
+
+
{t("Recorded evidence")} {data.source_count.toLocaleString()}
+
{t("Stored semantic evidence")} {data.derived_count.toLocaleString()}
+
{t("Records in multiple voice categories")} {data.multi_membership.toLocaleString()}
+
{t("Needs review")} {data.disagreement.toLocaleString()}
+
{t("Records without voice evidence")} {data.unavailable.toLocaleString()}
+
+
+ {data.category_memberships.map((category) => (
+
+ {t(voiceLabels[category.voice_concept_code])} {" "}
+ {category.post_count.toLocaleString()} ({category.eligible_percentage.toFixed(1)}%)
+
+ ))}
+
+ {data.counts_overlap ? {t("One record may support several voice categories, so category counts can overlap.")}
: null}
+ {t("Review disagreements and records without voice evidence before using these classifications.")}
+
+ );
+}
diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts
index 3e1666a04..a3ef452c4 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -318,4 +318,16 @@ describe("locale-aware source labels", () => {
expect(t("Voice of Customer")).toBe(customerVoice);
expect(t("Public")).toBe(visibility);
});
+
+ it.each([
+ ["en", "Records in multiple voice categories", "Records without voice evidence"],
+ ["ko", "여러 글 유형에 해당하는 기록", "글 유형 근거가 없는 기록"],
+ ["zh", "属于多个声音类别的记录", "缺少声音证据的记录"],
+ ["ja", "複数の声カテゴリに該当する記録", "声の証拠がない記録"],
+ ["vi", "Bản ghi thuộc nhiều nhóm tiếng nói", "Bản ghi không có bằng chứng tiếng nói"],
+ ] as const)("keeps voice-summary metrics specific in %s", (locale, multiple, unavailable) => {
+ setLocale(locale);
+ expect(t("Records in multiple voice categories")).toBe(multiple);
+ expect(t("Records without voice evidence")).toBe(unavailable);
+ });
});
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 13b14c711..d44541d48 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -145,6 +145,19 @@ const TRANSLATIONS: Partial>> = {
"All visibility": "모든 공개 범위",
"Voice of Customer": "고객의 소리",
"Voice of Market": "시장의 소리",
+ "Voice of Customer's customer": "고객 고객사의 소리",
+ "Voice of Competitor": "경쟁사의 소리",
+ "Voice of Partner": "파트너의 소리",
+ "Voice evidence overview": "글 유형 근거 현황",
+ "Loading voice evidence...": "글 유형 근거를 불러오는 중입니다...",
+ "Voice evidence could not be loaded.": "글 유형 근거를 불러오지 못했습니다.",
+ "Retry voice evidence": "글 유형 근거 다시 시도",
+ "Compare voice classifications across {count} visible records.": "표시 가능한 기록 {count}건의 글 유형 근거를 비교합니다.",
+ "Records in multiple voice categories": "여러 글 유형에 해당하는 기록",
+ "Records without voice evidence": "글 유형 근거가 없는 기록",
+ "Needs review": "재검토 필요",
+ "One record may support several voice categories, so category counts can overlap.": "한 기록이 여러 글 유형을 뒷받침할 수 있어 항목별 건수는 중복될 수 있습니다.",
+ "Review disagreements and records without voice evidence before using these classifications.": "불일치와 글 유형 근거가 없는 기록을 확인한 뒤 분류 결과를 활용하세요.",
Public: "공개",
Private: "비공개",
"Newest first": "최신순",
@@ -668,6 +681,19 @@ const TRANSLATIONS: Partial>> = {
"All visibility": "所有可见范围",
"Voice of Customer": "客户之声",
"Voice of Market": "市场之声",
+ "Voice of Customer's customer": "客户的客户之声",
+ "Voice of Competitor": "竞争对手之声",
+ "Voice of Partner": "合作伙伴之声",
+ "Voice evidence overview": "声音分类证据概览",
+ "Loading voice evidence...": "正在加载声音分类证据...",
+ "Voice evidence could not be loaded.": "无法加载声音分类证据。",
+ "Retry voice evidence": "重试声音分类证据",
+ "Compare voice classifications across {count} visible records.": "比较 {count} 条可见记录中的声音分类证据。",
+ "Records in multiple voice categories": "属于多个声音类别的记录",
+ "Records without voice evidence": "缺少声音证据的记录",
+ "Needs review": "需要复核",
+ "One record may support several voice categories, so category counts can overlap.": "一条记录可能支持多个声音类别,因此各类别计数可能重叠。",
+ "Review disagreements and records without voice evidence before using these classifications.": "使用这些分类前,请检查不一致项和缺少声音证据的记录。",
Public: "公开",
Private: "私有",
"Newest first": "最新优先",
@@ -1207,6 +1233,19 @@ const TRANSLATIONS: Partial>> = {
"All visibility": "すべての公開範囲",
"Voice of Customer": "顧客の声",
"Voice of Market": "市場の声",
+ "Voice of Customer's customer": "顧客の顧客の声",
+ "Voice of Competitor": "競合の声",
+ "Voice of Partner": "パートナーの声",
+ "Voice evidence overview": "声分類の証拠概要",
+ "Loading voice evidence...": "声分類の証拠を読み込んでいます...",
+ "Voice evidence could not be loaded.": "声分類の証拠を読み込めませんでした。",
+ "Retry voice evidence": "声分類の証拠を再試行",
+ "Compare voice classifications across {count} visible records.": "表示可能な記録 {count} 件の声分類証拠を比較します。",
+ "Records in multiple voice categories": "複数の声カテゴリに該当する記録",
+ "Records without voice evidence": "声の証拠がない記録",
+ "Needs review": "要確認",
+ "One record may support several voice categories, so category counts can overlap.": "1件の記録が複数の声カテゴリを裏付ける場合があるため、カテゴリ件数は重複します。",
+ "Review disagreements and records without voice evidence before using these classifications.": "不一致と声の証拠がない記録を確認してから、分類結果を利用してください。",
Public: "公開",
Private: "非公開",
"Newest first": "新しい順",
@@ -1725,6 +1764,19 @@ const TRANSLATIONS: Partial>> = {
"All visibility": "Tất cả phạm vi hiển thị",
"Voice of Customer": "Tiếng nói khách hàng",
"Voice of Market": "Tiếng nói thị trường",
+ "Voice of Customer's customer": "Tiếng nói khách hàng của khách hàng",
+ "Voice of Competitor": "Tiếng nói đối thủ",
+ "Voice of Partner": "Tiếng nói đối tác",
+ "Voice evidence overview": "Tổng quan bằng chứng phân loại tiếng nói",
+ "Loading voice evidence...": "Đang tải bằng chứng phân loại tiếng nói...",
+ "Voice evidence could not be loaded.": "Không thể tải bằng chứng phân loại tiếng nói.",
+ "Retry voice evidence": "Thử lại bằng chứng phân loại tiếng nói",
+ "Compare voice classifications across {count} visible records.": "So sánh bằng chứng phân loại tiếng nói trong {count} bản ghi có thể xem.",
+ "Records in multiple voice categories": "Bản ghi thuộc nhiều nhóm tiếng nói",
+ "Records without voice evidence": "Bản ghi không có bằng chứng tiếng nói",
+ "Needs review": "Cần xem lại",
+ "One record may support several voice categories, so category counts can overlap.": "Một bản ghi có thể hỗ trợ nhiều nhóm tiếng nói nên số lượng theo nhóm có thể trùng lặp.",
+ "Review disagreements and records without voice evidence before using these classifications.": "Hãy xem lại điểm bất đồng và bản ghi thiếu bằng chứng tiếng nói trước khi dùng các phân loại này.",
Public: "Công khai",
Private: "Riêng tư",
"Newest first": "Mới nhất trước",
diff --git a/migrations/0230_voice_semantic_taxonomy.sql b/migrations/0230_voice_semantic_taxonomy.sql
new file mode 100644
index 000000000..8a1ff4ef0
--- /dev/null
+++ b/migrations/0230_voice_semantic_taxonomy.sql
@@ -0,0 +1,200 @@
+-- ADR 0230: source-preserving, multi-membership voice taxonomy assertions.
+create table if not exists post_voice_classification_assertion (
+ classification_assertion_id uuid primary key default gen_random_uuid(),
+ post_id uuid not null references source_post(post_id) on delete cascade,
+ voice_concept_code text not null
+ check (voice_concept_code in ('voc', 'vocc', 'voco', 'vom', 'vop')),
+ assertion_status_code text not null
+ check (assertion_status_code in ('source', 'derived')),
+ evidence_span_start integer,
+ evidence_span_end integer,
+ evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'),
+ source_revision_digest text not null
+ check (source_revision_digest ~ '^[0-9a-f]{64}$'),
+ orchestrator_model_receipt text,
+ valid_from timestamptz,
+ valid_to timestamptz,
+ recorded_at timestamptz not null default now(),
+ supersedes_assertion_id uuid references post_voice_classification_assertion(classification_assertion_id),
+ check ((evidence_span_start is null) = (evidence_span_end is null)),
+ check (evidence_span_start is null or (evidence_span_start >= 0 and evidence_span_end > evidence_span_start)),
+ check (valid_to is null or valid_from is null or valid_to >= valid_from)
+);
+do $migration$
+begin
+ if not exists (
+ select 1 from pg_constraint
+ where conrelid = 'post_voice_classification_assertion'::regclass
+ and conname = 'post_voice_derived_receipt_check'
+ ) then
+ alter table post_voice_classification_assertion
+ add constraint post_voice_derived_receipt_check check (
+ assertion_status_code = 'source'
+ or (
+ evidence_span_start is not null
+ and orchestrator_model_receipt is not null
+ and btrim(orchestrator_model_receipt) <> ''
+ )
+ );
+ end if;
+end
+$migration$;
+create index if not exists post_voice_assertion_scope_idx
+ on post_voice_classification_assertion (post_id, valid_from, voice_concept_code);
+drop index if exists post_voice_assertion_idempotency_idx;
+with ranked_open_assertion as (
+ select classification_assertion_id,
+ row_number() over (
+ partition by post_id, assertion_status_code, voice_concept_code
+ order by recorded_at desc, classification_assertion_id desc
+ ) as duplicate_rank
+ from post_voice_classification_assertion
+ where valid_to is null
+)
+update post_voice_classification_assertion assertion
+ set valid_to = greatest(current_timestamp, assertion.valid_from)
+ from ranked_open_assertion ranked
+ where assertion.classification_assertion_id = ranked.classification_assertion_id
+ and ranked.duplicate_rank > 1;
+create unique index if not exists post_voice_assertion_open_scope_idx
+ on post_voice_classification_assertion
+ (post_id, assertion_status_code, voice_concept_code)
+ where valid_to is null;
+
+insert into post_voice_classification_assertion (
+ post_id, voice_concept_code, assertion_status_code,
+ evidence_sha256, source_revision_digest
+)
+select post.post_id,
+ lower(post.voc_type_code),
+ 'source',
+ encode(sha256(convert_to(post.voc_type_code, 'UTF8')), 'hex'),
+ encode(sha256(convert_to(coalesce(post.post_body, ''), 'UTF8')), 'hex')
+ from source_post post
+ where lower(post.voc_type_code) in ('voc', 'vocc', 'voco', 'vom', 'vop')
+on conflict (post_id, assertion_status_code, voice_concept_code)
+where valid_to is null
+do nothing;
+
+-- Source labels are recorded provenance, not future business-event claims.
+-- Repair rows written by an earlier replay of this migration without changing
+-- a separately sourced assertion that happens to share the post and concept.
+update post_voice_classification_assertion assertion
+ set valid_from = null
+ from source_post post
+ where assertion.post_id = post.post_id
+ and assertion.assertion_status_code = 'source'
+ and assertion.voice_concept_code = lower(post.voc_type_code)
+ and assertion.evidence_sha256 =
+ encode(sha256(convert_to(post.voc_type_code, 'UTF8')), 'hex')
+ and assertion.source_revision_digest =
+ encode(sha256(convert_to(coalesce(post.post_body, ''), 'UTF8')), 'hex')
+ and assertion.valid_from is not null;
+
+create or replace function reconcile_post_voice_source_assertion()
+returns trigger
+language plpgsql
+as $function$
+declare
+ current_evidence_sha256 text;
+ current_revision_digest text;
+ matching_assertion_id uuid;
+ prior_assertion_id uuid;
+begin
+ if lower(coalesce(new.voc_type_code, '')) not in
+ ('voc', 'vocc', 'voco', 'vom', 'vop') then
+ update post_voice_classification_assertion
+ set valid_to = current_timestamp
+ where post_id = new.post_id
+ and assertion_status_code = 'source'
+ and voice_concept_code = lower(coalesce(old.voc_type_code, new.voc_type_code))
+ and valid_to is null;
+ return new;
+ end if;
+
+ current_evidence_sha256 :=
+ encode(sha256(convert_to(new.voc_type_code, 'UTF8')), 'hex');
+ current_revision_digest :=
+ encode(sha256(convert_to(coalesce(new.post_body, ''), 'UTF8')), 'hex');
+
+ select classification_assertion_id
+ into matching_assertion_id
+ from post_voice_classification_assertion
+ where post_id = new.post_id
+ and assertion_status_code = 'source'
+ and voice_concept_code = lower(new.voc_type_code)
+ and evidence_sha256 = current_evidence_sha256
+ and source_revision_digest = current_revision_digest
+ and valid_to is null
+ order by recorded_at desc, classification_assertion_id
+ limit 1;
+
+ if matching_assertion_id is not null then
+ update post_voice_classification_assertion
+ set valid_to = current_timestamp
+ where post_id = new.post_id
+ and assertion_status_code = 'source'
+ and voice_concept_code = lower(coalesce(old.voc_type_code, new.voc_type_code))
+ and valid_to is null
+ and classification_assertion_id <> matching_assertion_id;
+ return new;
+ end if;
+
+ select classification_assertion_id
+ into prior_assertion_id
+ from post_voice_classification_assertion
+ where post_id = new.post_id
+ and assertion_status_code = 'source'
+ and voice_concept_code = lower(coalesce(old.voc_type_code, new.voc_type_code))
+ and valid_to is null
+ order by recorded_at desc, classification_assertion_id
+ limit 1;
+
+ update post_voice_classification_assertion
+ set valid_to = current_timestamp
+ where post_id = new.post_id
+ and assertion_status_code = 'source'
+ and voice_concept_code = lower(coalesce(old.voc_type_code, new.voc_type_code))
+ and valid_to is null;
+
+ insert into post_voice_classification_assertion (
+ post_id, voice_concept_code, assertion_status_code,
+ evidence_sha256, source_revision_digest, supersedes_assertion_id
+ ) values (
+ new.post_id, lower(new.voc_type_code), 'source',
+ current_evidence_sha256, current_revision_digest, prior_assertion_id
+ )
+ on conflict (
+ post_id, assertion_status_code, voice_concept_code
+ ) where valid_to is null do nothing;
+ return new;
+end
+$function$;
+
+drop trigger if exists source_post_voice_assertion_reconcile on source_post;
+create trigger source_post_voice_assertion_reconcile
+after insert or update of voc_type_code, post_body on source_post
+for each row execute function reconcile_post_voice_source_assertion();
+
+create table if not exists organization_voice_relationship_assertion (
+ relationship_assertion_id uuid primary key default gen_random_uuid(),
+ post_id uuid not null references source_post(post_id) on delete cascade,
+ corporate_entity_id uuid not null references corporate_entity(corporate_entity_id),
+ relationship_concept_code text not null
+ check (relationship_concept_code in ('rel_voc', 'rel_vocc', 'rel_voco', 'rel_vom', 'rel_vop', 'rel_vos')),
+ evidence_span_start integer not null check (evidence_span_start >= 0),
+ evidence_span_end integer not null check (evidence_span_end > evidence_span_start),
+ evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'),
+ source_revision_digest text not null
+ check (source_revision_digest ~ '^[0-9a-f]{64}$'),
+ orchestrator_model_receipt text not null check (btrim(orchestrator_model_receipt) <> ''),
+ product_catalog_id uuid references product_catalog(product_catalog_id),
+ valid_from timestamptz,
+ valid_to timestamptz,
+ recorded_at timestamptz not null default now(),
+ supersedes_assertion_id uuid references organization_voice_relationship_assertion(relationship_assertion_id),
+ check (valid_to is null or valid_from is null or valid_to >= valid_from)
+);
+create index if not exists organization_voice_assertion_scope_idx
+ on organization_voice_relationship_assertion
+ (corporate_entity_id, valid_from, relationship_concept_code, post_id);
diff --git a/tests/test_ontology_shapes.py b/tests/test_ontology_shapes.py
index d26b4f803..67d0fc0f5 100644
--- a/tests/test_ontology_shapes.py
+++ b/tests/test_ontology_shapes.py
@@ -225,3 +225,31 @@ def test_confidence_boundary_values_are_inclusive() -> None:
)
conforms, report_text = _conforms(data)
assert conforms, f"{value} rejected:\n{report_text}"
+
+
+def test_derived_voice_assertion_requires_receipt_and_ordered_source_span() -> None:
+ """Derived voice RDF cannot omit the receipt or its exact source span."""
+ data = _representative_projection()
+ voice = URIRef(LW + "voice-assertion-alpha")
+ post = URIRef(LW + "post-alpha")
+ prov = Namespace("http://www.w3.org/ns/prov#")
+ LWn = Namespace(LW)
+ for predicate, value in (
+ (RDF.type, LWn.PostVoiceClassificationAssertion),
+ (LWn.voiceConceptCode, Literal("voc")),
+ (LWn.voiceAssertionStatus, Literal("derived")),
+ (LWn.voiceEvidenceDigest, Literal("a" * 64)),
+ (LWn.sourceRevisionDigest, Literal("b" * 64)),
+ (prov.wasDerivedFrom, post),
+ ):
+ data.add((voice, predicate, value))
+
+ conforms, report_text = _conforms(data)
+ assert not conforms
+ assert "orchestratorModelReceipt" in report_text
+
+ data.add((voice, LWn.orchestratorModelReceipt, Literal("synthetic-receipt")))
+ data.add((voice, LWn.evidenceSpanStart, Literal(0, datatype=XSD.integer)))
+ data.add((voice, LWn.evidenceSpanEnd, Literal(12, datatype=XSD.integer)))
+ conforms, report_text = _conforms(data)
+ assert conforms, report_text
From fef16f69bc48a5e02d951e2cf12d479490d231fd Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 17:35:49 +0900
Subject: [PATCH 185/393] fix(runtime): prioritize dashboard case evidence
---
backend/app/post_content_worker.py | 24 +-
...98-valkey-backed-post-content-ingestion.md | 8 +
lineageweave/data/lineageweave-kg.ttl | 513 ++++++++++++++++++
lineageweave/ontology.py | 9 +-
pyproject.toml | 3 +
tests/test_ontology.py | 8 +
tests/test_post_content_worker.py | 4 +
7 files changed, 555 insertions(+), 14 deletions(-)
create mode 100644 lineageweave/data/lineageweave-kg.ttl
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index f3ffee33b..92dfba329 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -532,6 +532,18 @@ async def process_post_content_job(
evidence_sources = await _operations_evidence_sources(
pool, post_id, row, vision_client
)
+ await _persist_operations_case_analysis_if_needed(
+ pool,
+ post_id,
+ source_body_digest,
+ raw_body,
+ row,
+ vision_client,
+ metadata["lineageweave_post_session_id"],
+ settings.orchestrator_base_url,
+ settings.orchestrator_api_key,
+ evidence_sources,
+ )
try:
await _persist_product_analysis_if_needed(
pool,
@@ -553,18 +565,6 @@ async def process_post_content_job(
exc,
outcome="provider_unavailable",
)
- await _persist_operations_case_analysis_if_needed(
- pool,
- post_id,
- source_body_digest,
- raw_body,
- row,
- vision_client,
- metadata["lineageweave_post_session_id"],
- settings.orchestrator_base_url,
- settings.orchestrator_api_key,
- evidence_sources,
- )
normalized = await asyncio.to_thread(
normalize_post_body, raw_body, vision_client
)
diff --git a/docs/adr/0098-valkey-backed-post-content-ingestion.md b/docs/adr/0098-valkey-backed-post-content-ingestion.md
index ce2ce21d9..8ba786d29 100644
--- a/docs/adr/0098-valkey-backed-post-content-ingestion.md
+++ b/docs/adr/0098-valkey-backed-post-content-ingestion.md
@@ -126,6 +126,14 @@ stale worker cannot defer a newer lease. Recovery publishes the row only after
failures retain the existing three-attempt accounting. Raw upstream error text,
agent identity, prompt, and response are neither stored nor shown to a reader.
+Operations-case analysis is the Dashboard acceptance channel and runs before
+optional product extraction inside a claimed job. Each channel commits through
+its own existing persistence transaction while retaining the same post-scoped
+session and exact body digest. A later product extraction failure therefore
+cannot erase an already committed operations case, and product latency cannot
+delay admission of the case request. This is execution isolation, not a new
+queue or a change to either channel's evidence contract.
+
### Operational timeout for structure adjudication
The contextual-orchestrator structure adjudication request uses a 600-second client timeout by default. Structure inference is an accuracy-critical, structured multi-agent operation rather than a user-facing synchronous request; the longer bound prevents a slow but valid workflow from being downgraded to `unresolved` merely because the client abandoned the response. The durable job remains queued until all non-image units have complete structure evidence.
diff --git a/lineageweave/data/lineageweave-kg.ttl b/lineageweave/data/lineageweave-kg.ttl
new file mode 100644
index 000000000..f59b50650
--- /dev/null
+++ b/lineageweave/data/lineageweave-kg.ttl
@@ -0,0 +1,513 @@
+@prefix : .
+@prefix owl: .
+@prefix rdf: .
+@prefix rdfs: .
+@prefix skos: .
+@prefix xsd: .
+@prefix prov: .
+@prefix org: .
+
+#################################################################
+# LineageWeave Knowledge Graph Ontology
+#
+# The formal OWL 2 Full / RDFS / SKOS vocabulary for the
+# `knowledge_graph_edge` table's node/edge types, the
+# `entity_relationship_type` / `person_side` / `corporate_entity_level`
+# / `voc_type` controlled vocabularies in migrations/, and
+# `post_summary_role.actor_type_code` (migrations/0012).
+#
+# ADR 0207 supersedes ADR 0157: the canonical namespace is the
+# repository-case spelling above -- the exact path GitHub Pages serves.
+# The lowercase namespace is a deprecated compatibility vocabulary
+# published beside this file as namespace-compatibility.ttl with
+# validated term-kind mappings; new producers must not mint lowercase
+# IRIs.
+#
+# `knowledge_graph_edge` (source_node_type_code, source_node_id) --
+# [edge_type_code] --> (target_node_type_code, target_node_id) is
+# already an RDF triple in shape (Cyganiak, Wood, & Lanthaler, 2014);
+# this file is the formal semantic layer over it -- PostgreSQL stays
+# the source of record. See docs/adr/0004-knowledge-graph-ontology.md
+# for the KG design rationale, docs/adr/0207-repository-case-ontology-namespace-canonical.md
+# for the namespace decision and SHACL boundary, and tests/test_ontology.py
+# for the round-trip check that every lookup code below actually exists
+# as a common_lookup_value row, and vice versa.
+#
+# Every controlled-vocabulary term carries a :lookupCode annotation
+# naming the exact `common_lookup_value.lookup_code` it corresponds to
+# -- that literal string, not the IRI fragment, is what the relational
+# schema stores. Column-projection datatype properties deliberately do
+# NOT carry :lookupCode: they project table columns, not governed
+# lookup rows, so there is nothing for the round-trip check to enforce
+# (the same discipline as the organization_name_resolution block below).
+#################################################################
+
+ a owl:Ontology ;
+ rdfs:label "LineageWeave Knowledge Graph Ontology" ;
+ rdfs:comment "Formal OWL 2 Full / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, corporate_entity_level, voc_type, and post_summary_role.actor_type_code controlled vocabularies. RDF reification for semantic project evidence is interpreted with OWL 2 RDF-Based Semantics rather than OWL 2 DL." .
+
+:lookupCode a owl:AnnotationProperty ;
+ rdfs:label "lookup code" ;
+ rdfs:comment "The exact common_lookup_value.lookup_code string this ontology term corresponds to." .
+
+#################################################################
+# Classes -- node_type
+#################################################################
+
+:Post a owl:Class ;
+ rdfs:label "Post" ;
+ rdfs:comment "A source_post row: one record typed by the voc_type scheme (:postTypeScheme) -- Voice of Customer, Customer's Customer, Competitor, Market, or Partner." ;
+ :lookupCode "node_post" .
+
+:Person a owl:Class ;
+ rdfs:label "Person" ;
+ rdfs:comment "A cataloged_person row: a Keyman mentioned in one or more posts." ;
+ :lookupCode "node_person" .
+
+:OurSidePerson a owl:Class ;
+ rdfs:subClassOf :Person ;
+ rdfs:label "Our-side person" ;
+ :lookupCode "our_side" .
+
+:CounterpartyPerson a owl:Class ;
+ rdfs:subClassOf :Person ;
+ rdfs:label "Counterparty person" ;
+ :lookupCode "counterparty" .
+
+# A person side is exactly one of our-side or counterparty (the seeded
+# person_side vocabulary has no third value), so the two subclasses are
+# declared disjoint: a reasoner must never infer both from one row, and
+# the SHACL shapes graph carries the closed-world complement.
+:OurSidePerson owl:disjointWith :CounterpartyPerson .
+
+:CorporateEntity a owl:Class ;
+ rdfs:subClassOf skos:Concept ;
+ rdfs:label "Corporate entity" ;
+ rdfs:comment "A corporate_entity row. Also a skos:Concept so the self-referencing parent_entity_id hierarchy (e.g. Group -> Company -> Plant) is expressible with skos:broader/skos:narrower on instances." ;
+ :lookupCode "node_corporate_entity" .
+
+:Team a owl:Class ;
+ rdfs:subClassOf org:OrganizationalUnit ;
+ rdfs:label "Team" ;
+ rdfs:comment "A cataloged_team row: a named company sub-unit (ADR 0009) with a stable team_id, distinct from :RoleActorTeam (ADR 0007's per-row actor_type_code classification) the same way :Person is distinct from :RoleActorPerson." ;
+ :lookupCode "node_team" .
+
+#################################################################
+# Object properties -- edge_type (knowledge_graph_edge.edge_type_code)
+#################################################################
+
+:mentionedIn a owl:ObjectProperty ;
+ rdfs:domain :Person ;
+ rdfs:range :Post ;
+ rdfs:label "mentioned in" ;
+ rdfs:comment "A person is named by a post (post_person_mention); this is the canonical direction stored by knowledge_graph_edge." ;
+ :lookupCode "edge_mention" .
+
+# Keep the natural-language inverse available to RDF consumers without
+# assigning the relational lookup code to two different properties.
+:mentions a owl:ObjectProperty ;
+ rdfs:domain :Post ;
+ rdfs:range :Person ;
+ rdfs:label "mentions" ;
+ owl:inverseOf :mentionedIn .
+
+:affiliatedWith a owl:ObjectProperty ;
+ rdfs:domain :Person ;
+ rdfs:range :CorporateEntity ;
+ rdfs:label "affiliated with" ;
+ rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ;
+ :lookupCode "edge_affiliation" .
+
+# Bidirectional query support for affiliations: consumers can traverse
+# entity -> people without a second stored edge. Like :mentions above,
+# the inverse stays un-coded so one lookup_code keeps naming exactly one
+# stored property.
+:hasAffiliate a owl:ObjectProperty ;
+ rdfs:domain :CorporateEntity ;
+ rdfs:range :Person ;
+ rdfs:label "has affiliate" ;
+ owl:inverseOf :affiliatedWith .
+
+:coMentionedWith a owl:ObjectProperty, owl:SymmetricProperty ;
+ rdfs:domain :Person ;
+ rdfs:range :Person ;
+ rdfs:label "co-mentioned with" ;
+ rdfs:comment "Two people named in the same post -- symmetric by construction." ;
+ :lookupCode "edge_co_mention" .
+
+#################################################################
+# Object properties -- ADR 0009 cross-post identity resolution edges.
+# Kept distinct from :mentionedIn/:affiliatedWith (not reused with a
+# broadened domain/range) so an edge_type_code alone always tells you
+# which node types it connects -- stating rdfs:domain for the same
+# property twice (once :Person, once :Team) would make RDFS entail
+# every :mentionedIn subject is BOTH a :Person and a :Team, which is false.
+#################################################################
+
+:mentionsTeam a owl:ObjectProperty ;
+ rdfs:domain :Team ;
+ rdfs:range :Post ;
+ rdfs:label "mentioned in post" ;
+ rdfs:comment "A cataloged team is named by a post (post_team_mention)." ;
+ :lookupCode "edge_mention_team" .
+
+:teamAffiliatedWith a owl:ObjectProperty ;
+ rdfs:domain :Team ;
+ rdfs:range :CorporateEntity ;
+ rdfs:label "team affiliated with" ;
+ rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ;
+ :lookupCode "edge_team_affiliation" .
+
+:mentionsOrganization a owl:ObjectProperty ;
+ rdfs:domain :CorporateEntity ;
+ rdfs:range :Post ;
+ rdfs:label "mentioned in post" ;
+ rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ;
+ :lookupCode "edge_mention_organization" .
+
+#################################################################
+# Object properties -- entity_relationship_type
+# (post_counterparty_entity.relationship_type_code)
+#################################################################
+
+:hasVocRelationship a owl:ObjectProperty ;
+ rdfs:domain :Post ; rdfs:range :CorporateEntity ;
+ rdfs:label "has Voice-of-Customer relationship" ;
+ :lookupCode "rel_voc" .
+
+:hasVomRelationship a owl:ObjectProperty ;
+ rdfs:domain :Post ; rdfs:range :CorporateEntity ;
+ rdfs:label "has Voice-of-Market relationship" ;
+ :lookupCode "rel_vom" .
+
+:hasVopRelationship a owl:ObjectProperty ;
+ rdfs:domain :Post ; rdfs:range :CorporateEntity ;
+ rdfs:label "has Voice-of-Partner relationship" ;
+ :lookupCode "rel_vop" .
+
+:hasVoccRelationship a owl:ObjectProperty ;
+ rdfs:domain :Post ; rdfs:range :CorporateEntity ;
+ rdfs:label "has Voice-of-Customer's-Customer relationship" ;
+ :lookupCode "rel_vocc" .
+
+:hasVocoRelationship a owl:ObjectProperty ;
+ rdfs:domain :Post ; rdfs:range :CorporateEntity ;
+ rdfs:label "has Voice-of-Competitor relationship" ;
+ :lookupCode "rel_voco" .
+
+:hasVosRelationship a owl:ObjectProperty ;
+ rdfs:domain :Post ; rdfs:range :CorporateEntity ;
+ rdfs:label "has Voice-of-Supplier relationship" ;
+ :lookupCode "rel_vos" .
+
+#################################################################
+# Datatype properties -- node attribute projections.
+#
+# These project real source columns (source_post.post_title /
+# post_body / created_at / updated_at / event_occurred_at;
+# cataloged_person.person_name / last_known_job_title;
+# corporate_entity.corporate_entity_code / entity_name). No property
+# is minted for a column that does not exist. Shared timestamps carry
+# NO rdfs:domain on purpose: two rdfs:domain statements would entail
+# every subject belongs to BOTH classes -- the multi-domain trap the
+# cross-post edge block above already avoids. Per-class cardinality
+# and datatype constraints live in the SHACL shapes graph
+# (lineageweave-kg-shapes.ttl), which validates projected data
+# closed-world where OWL's open world deliberately will not
+# (Knublauch & Kontokostas, 2017).
+#################################################################
+
+:postTitle a owl:DatatypeProperty ;
+ rdfs:domain :Post ;
+ rdfs:range xsd:string ;
+ rdfs:label "post title" ;
+ rdfs:comment "source_post.post_title -- the authoring application's title text." .
+
+:postBody a owl:DatatypeProperty ;
+ rdfs:domain :Post ;
+ rdfs:range xsd:string ;
+ rdfs:label "post body" ;
+ rdfs:comment "source_post.post_body -- the preserved source representation, never flattened into one opaque string by derived views." .
+
+:eventOccurredAt a owl:DatatypeProperty ;
+ rdfs:domain :Post ;
+ rdfs:range xsd:dateTime ;
+ rdfs:label "event occurred at" ;
+ rdfs:comment "source_post.event_occurred_at (migrations 0183) -- the business event instant Global Ask time filters bind to, falling back to created_at only when missing (ADR 0150)." .
+
+:personName a owl:DatatypeProperty ;
+ rdfs:domain :Person ;
+ rdfs:range xsd:string ;
+ rdfs:label "person name" ;
+ rdfs:comment "cataloged_person.person_name -- Keyman extraction tests the raw organization name before any abbreviation rewrite so a rewrite cannot turn an existing tie into an apparent creation miss (ADR 0026)." .
+
+:lastKnownJobTitle a owl:DatatypeProperty ;
+ rdfs:domain :Person ;
+ rdfs:range xsd:string ;
+ rdfs:label "last known job title" ;
+ rdfs:comment "cataloged_person.last_known_job_title (migrations 0013) -- a stated title is real same-name disambiguation evidence even when no affiliation row exists." .
+
+:entityName a owl:DatatypeProperty ;
+ rdfs:domain :CorporateEntity ;
+ rdfs:range xsd:string ;
+ rdfs:label "entity name" ;
+ rdfs:comment "corporate_entity.entity_name -- the human-readable hierarchy label; corporate similarity results stay unique/miss/tie over this name (ADR 0026)." .
+
+:entityCode a owl:DatatypeProperty ;
+ rdfs:domain :CorporateEntity ;
+ rdfs:range xsd:string ;
+ rdfs:label "entity code" ;
+ rdfs:comment "corporate_entity.corporate_entity_code -- the short corp code carried at login time, distinct from the display name." .
+
+# Shared record timestamps apply to every KG node kind, so they declare
+# no domain (see the block comment above); the shapes graph pins them
+# per class.
+:createdAt a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime ;
+ rdfs:label "created at" ;
+ rdfs:comment "Record creation instant shared across node kinds (each source table's created_at); no rdfs:domain because multiple domains would entail impossible co-membership." .
+
+:updatedAt a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime ;
+ rdfs:label "updated at" ;
+ rdfs:comment "Record last-write instant shared across node kinds (e.g. source_post.updated_at); null updated-at falls back to created_at at import boundaries." .
+
+#################################################################
+# SKOS -- voc_type (post type classification)
+#
+# The five-value VOC source vocabulary migrations/0042 governs. There
+# are exactly five seeded codes: vos exists only as a relationship type
+# (rel_vos above), never as a post type, so no Voice-of-Supplier concept
+# belongs here. Adding "voc_type" to the ontology-covered categories
+# puts these codes under tests/test_ontology.py's round-trip check --
+# closing the previously documented expected gap.
+#################################################################
+
+:postTypeScheme a skos:ConceptScheme ;
+ rdfs:label "Post type scheme" ;
+ rdfs:comment "Voice-based classification of what a source post records, per the governed five-value voc_type lookup category (migrations/0042)." .
+
+:voiceOfCustomerType a skos:Concept ;
+ skos:inScheme :postTypeScheme ;
+ skos:prefLabel "Voice of Customer"@en ;
+ rdfs:comment "A customer's own voice about their experience." ;
+ :lookupCode "voc" .
+
+:voiceOfCustomersCustomerType a skos:Concept ;
+ skos:inScheme :postTypeScheme ;
+ skos:prefLabel "Voice of Customer's Customer"@en ;
+ rdfs:comment "The voice of the customer's downstream customer." ;
+ :lookupCode "vocc" .
+
+:voiceOfCompetitorType a skos:Concept ;
+ skos:inScheme :postTypeScheme ;
+ skos:prefLabel "Voice of Competitor"@en ;
+ rdfs:comment "Market intelligence sourced from a competitor." ;
+ :lookupCode "voco" .
+
+:voiceOfMarketType a skos:Concept ;
+ skos:inScheme :postTypeScheme ;
+ skos:prefLabel "Voice of Market"@en ;
+ rdfs:comment "General market signal not attributable to one account or partner." ;
+ :lookupCode "vom" .
+
+:voiceOfPartnerType a skos:Concept ;
+ skos:inScheme :postTypeScheme ;
+ skos:prefLabel "Voice of Partner"@en ;
+ rdfs:comment "A partner organization's voice." ;
+ :lookupCode "vop" .
+
+#################################################################
+# SKOS -- corporate_entity_level (Group -> Company -> Plant)
+#################################################################
+
+:corporateEntityLevelScheme a skos:ConceptScheme ;
+ rdfs:label "Corporate entity level scheme" ;
+ rdfs:comment "The Acme Group -> Acme Electronics Korea -> Acme Electronics Gwangju Plant kind of level, ordered broadest first." .
+
+:GroupLevel a skos:Concept ;
+ skos:inScheme :corporateEntityLevelScheme ;
+ skos:prefLabel "Group"@en ;
+ :lookupCode "group" .
+
+:CompanyLevel a skos:Concept ;
+ skos:inScheme :corporateEntityLevelScheme ;
+ skos:broader :GroupLevel ;
+ skos:prefLabel "Company"@en ;
+ :lookupCode "company" .
+
+:PlantLevel a skos:Concept ;
+ skos:inScheme :corporateEntityLevelScheme ;
+ skos:broader :CompanyLevel ;
+ skos:prefLabel "Plant"@en ;
+ :lookupCode "plant" .
+
+:GroupLevel skos:narrower :CompanyLevel .
+:CompanyLevel skos:narrower :PlantLevel .
+
+#################################################################
+# Classes -- prov_agent_type (post_summary_role.actor_type_code)
+#
+# A post's R&R (roles & responsibilities) actor is not always a person
+# -- business correspondence routinely names an organization acting
+# in its own name ("당사" [our company], "Demo Corp"). Grounded
+# directly in W3C PROV-O (Lebo, Sahoo, & McGuinness,
+# 2013): prov:Agent is the general acting-party class, with prov:Person
+# and prov:Organization its two recognized subclasses. These are
+# distinct from :Person / :OurSidePerson / :CounterpartyPerson above:
+# node_type's :Person is a cataloged_person row with a stable person_id
+# a Keyman panel links to; an R&R actor is a free-text name with no
+# cataloged identity of its own (it may not even resolve to a Keyman).
+#
+# A third, meso-level case real data surfaced: a named sub-unit of a
+# company ("설계팀" [design team]) is neither prov:Person nor the
+# prov:Organization itself -- it is the company's own internal
+# structure. PROV-O has no such class; the W3C Organization Ontology
+# (Reynolds, 2014) does: org:OrganizationalUnit, "used to represent
+# division of a particular organization into sub-organizational units,"
+# linked to its parent via org:unitOf. See docs/adr/0007-team-actor-type.md.
+#
+# Keyman job titles and industry sectors remain free-text columns with
+# no governed lookup category, so no SKOS scheme is invented for them
+# here (ADR 0207 decision 8 tracks that gap rather than fabricating
+# vocabulary).
+#################################################################
+
+:RoleActorPerson a owl:Class ;
+ rdfs:subClassOf prov:Person ;
+ rdfs:label "Role actor (person)" ;
+ rdfs:comment "An R&R actor that is a named individual, per prov:Person." ;
+ :lookupCode "prov_person" .
+
+:RoleActorOrganization a owl:Class ;
+ rdfs:subClassOf prov:Organization ;
+ rdfs:label "Role actor (organization)" ;
+ rdfs:comment "An R&R actor that is an organization acting in its own name, per prov:Organization." ;
+ :lookupCode "prov_organization" .
+
+:RoleActorTeam a owl:Class ;
+ rdfs:subClassOf org:OrganizationalUnit ;
+ rdfs:label "Role actor (team)" ;
+ rdfs:comment "An R&R actor that is a named sub-unit of a company (e.g. 설계팀), per org:OrganizationalUnit -- not the company itself." ;
+ :lookupCode "prov_team" .
+
+#################################################################
+# organization_name_resolution (raw/canonical organization-name pairs)
+#
+# ADR 0008: an abbreviated/slang organization mention (e.g. "AGP")
+# is resolved to its full canonical name ("Aurora Grid Power") and
+# cross-verified via external search before being trusted. This is not
+# a new KG node/edge type -- no new :lookupCode term is declared here,
+# since organization_name_resolution's columns are not a
+# common_lookup_value category (there is nothing for
+# tests/test_ontology.py's round-trip check to enforce). Documented
+# here for the Ontology/Semantic-Layer grounding itself:
+# `organization_name_resolution.raw_organization_name` corresponds to
+# SKOS `skos:altLabel` (an alternative label -- an abbreviation is
+# exactly this) and `resolved_organization_name` to `skos:prefLabel`
+# (the single preferred/canonical label), per Miles & Bechhofer (2009).
+#################################################################
+# Semantic project extraction (ADR 0036). These resources are distinct from
+# imported grouping fields: a post may mention a project without carrying a
+# project field, and the mention keeps evidence/confidence for review.
+:Project a owl:Class ;
+ rdfs:label "Project"@en ;
+ rdfs:comment "A business project referred to by a source post."@en .
+
+:ProjectMention a owl:Class ;
+ rdfs:subClassOf rdf:Statement,
+ [ a owl:Restriction ; owl:onProperty rdf:subject ; owl:allValuesFrom :Post ],
+ [ a owl:Restriction ; owl:onProperty rdf:predicate ; owl:hasValue :mentionsProject ],
+ [ a owl:Restriction ; owl:onProperty rdf:object ; owl:allValuesFrom :Project ] ;
+ rdfs:label "Project mention"@en ;
+ rdfs:comment "An evidence-backed, RDF-reified assertion that a post refers to a project; rdf:subject identifies the post, rdf:predicate is :mentionsProject, and rdf:object identifies the project."@en .
+
+:mentionsProject a owl:ObjectProperty ;
+ rdfs:domain :Post ;
+ rdfs:range :Project .
+
+:projectEvidence a owl:DatatypeProperty ;
+ rdfs:domain :ProjectMention ;
+ rdfs:range xsd:string .
+
+:semanticConfidence a owl:DatatypeProperty ;
+ rdfs:domain :ProjectMention ;
+ rdfs:range xsd:decimal .
+
+#################################################################
+# Evidence-grounded operations Dashboard (ADR 0206).
+#
+# These are governed read-projection terms over operations_case_* rows,
+# not knowledge_graph_edge aliases. Each reified fact retains its cited
+# source post through prov:wasDerivedFrom. Untyped legacy external-relation
+# rows stay outside the typed relation projection.
+#################################################################
+
+:OperationsCase a owl:Class ;
+ rdfs:subClassOf prov:Entity ;
+ rdfs:label "Operations case"@en .
+
+:ClaimInvestigation a owl:Class ;
+ rdfs:subClassOf :OperationsCase ;
+ rdfs:label "Claim investigation"@en .
+
+:RebidHandover a owl:Class ;
+ rdfs:subClassOf :OperationsCase ;
+ rdfs:label "Rebid or handover"@en .
+
+:ExternalInformation a owl:Class ;
+ rdfs:subClassOf :OperationsCase ;
+ rdfs:label "External information"@en .
+
+:RepeatIssue a owl:Class ;
+ rdfs:subClassOf :OperationsCase ;
+ rdfs:label "Repeat issue"@en .
+
+:OperationsCaseFact a owl:Class ;
+ rdfs:subClassOf rdf:Statement, prov:Entity ;
+ rdfs:label "Operations case fact"@en .
+
+:Product a owl:Class ;
+ rdfs:label "Product"@en ;
+ rdfs:comment "A governed product catalog identity at group, model, variant, or trade-item level."@en .
+
+:ProductMention a owl:Class ;
+ rdfs:label "Product mention"@en ;
+ rdfs:comment "A source-span-bound product mention with a fail-closed catalog resolution outcome."@en .
+
+:mentionsProduct a owl:ObjectProperty ;
+ rdfs:domain :ProductMention ; rdfs:range :Product .
+
+:extractedProductName a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:productResolutionStatus a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:evidenceInputDigest a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:Order a owl:Class ; rdfs:label "Order"@en .
+:SalesContext a owl:Class ; rdfs:label "Sales context"@en .
+:BusinessManagementContext a owl:Class ; rdfs:label "Business-management context"@en .
+
+:relatesToOrder a owl:ObjectProperty ;
+ rdfs:domain :ExternalInformation ; rdfs:range :Order .
+
+:relatesToProject a owl:ObjectProperty ;
+ rdfs:domain :ExternalInformation ; rdfs:range :Project .
+
+:relatesToSales a owl:ObjectProperty ;
+ rdfs:domain :ExternalInformation ; rdfs:range :SalesContext .
+
+:relatesToBusinessManagement a owl:ObjectProperty ;
+ rdfs:domain :ExternalInformation ; rdfs:range :BusinessManagementContext .
+
+:hasOperationsFact a owl:ObjectProperty ;
+ rdfs:domain :OperationsCase ; rdfs:range :OperationsCaseFact .
+
+:factTypeCode a owl:DatatypeProperty ;
+ rdfs:domain :OperationsCaseFact ; rdfs:range xsd:string .
+
+:factValue a owl:DatatypeProperty ;
+ rdfs:domain :OperationsCaseFact ; rdfs:range xsd:string .
diff --git a/lineageweave/ontology.py b/lineageweave/ontology.py
index 5d0b3d508..20e0d7b95 100644
--- a/lineageweave/ontology.py
+++ b/lineageweave/ontology.py
@@ -19,6 +19,7 @@
from __future__ import annotations
from pathlib import Path
+from importlib.resources import files
from rdflib import Graph, Namespace
from rdflib.namespace import OWL, RDF, RDFS, SKOS
@@ -35,7 +36,9 @@
#: `common_lookup_value.lookup_code` string it corresponds to.
LOOKUP_CODE = LW.lookupCode
-_ONTOLOGY_PATH = Path(__file__).resolve().parents[1] / "docs" / "ontology" / "lineageweave-kg.ttl"
+_SOURCE_ONTOLOGY_PATH = (
+ Path(__file__).resolve().parents[1] / "docs" / "ontology" / "lineageweave-kg.ttl"
+)
def load_ontology() -> Graph:
@@ -46,7 +49,9 @@ def load_ontology() -> Graph:
on import-time caching.
"""
graph = Graph()
- graph.parse(_ONTOLOGY_PATH, format="turtle")
+ packaged = files("lineageweave").joinpath("data", "lineageweave-kg.ttl")
+ ontology_path = _SOURCE_ONTOLOGY_PATH if _SOURCE_ONTOLOGY_PATH.is_file() else packaged
+ graph.parse(ontology_path, format="turtle")
return graph
diff --git a/pyproject.toml b/pyproject.toml
index f2e8bb1ef..e7a736f0c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -60,6 +60,9 @@ backend = [
[tool.setuptools.packages.find]
include = ["lineageweave*", "backend*"]
+[tool.setuptools.package-data]
+lineageweave = ["data/*.ttl"]
+
[tool.pytest.ini_options]
testpaths = ["tests", "backend/tests"]
pythonpath = ["."]
diff --git a/tests/test_ontology.py b/tests/test_ontology.py
index 0ef231bab..41e980423 100644
--- a/tests/test_ontology.py
+++ b/tests/test_ontology.py
@@ -89,6 +89,14 @@ def test_ontology_parses_as_valid_turtle() -> None:
assert len(graph) > 0
+def test_packaged_ontology_matches_publication_source() -> None:
+ """The installed runtime resource cannot drift from the published ontology."""
+ root = Path(__file__).resolve().parents[1]
+ packaged = root / "lineageweave" / "data" / "lineageweave-kg.ttl"
+ published = root / "docs" / "ontology" / "lineageweave-kg.ttl"
+ assert packaged.read_bytes() == published.read_bytes()
+
+
def test_every_seeded_lookup_code_is_declared_in_the_ontology() -> None:
seeded = _seeded_lookup_codes_for_covered_categories()
declared = all_declared_lookup_codes()
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index 81c7bd773..6323fc4c6 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -634,14 +634,17 @@ def test_invalid_product_output_does_not_block_primary_post_evidence(monkeypatch
outcomes: list[str] = []
persisted: list[str] = []
failures: list[tuple[str, str]] = []
+ channel_order: list[str] = []
async def claim(*_args, **_kwargs):
return _row(RUNNING, 1)
async def fail_product(*_args, **_kwargs):
+ channel_order.append("product")
raise RuntimeError("synthetic malformed product response")
async def persist_cases(*_args, **_kwargs):
+ channel_order.append("cases")
persisted.append("cases")
async def persist_content(*_args, **_kwargs):
@@ -704,6 +707,7 @@ async def finish(_pool, _post_id, status, **_kwargs):
)
assert persisted == ["cases", "content"]
+ assert channel_order == ["cases", "product"]
assert outcomes == [SUCCEEDED]
assert failures == [("product_semantic_ingestion", "provider_unavailable")]
From c4db94372905fe9ee7e6556545e56521f5f0dc7b Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 17:36:10 +0900
Subject: [PATCH 186/393] fix(package): sync packaged ontology vocabulary
---
lineageweave/data/lineageweave-kg.ttl | 33 +++++++++++++++++++++++++++
1 file changed, 33 insertions(+)
diff --git a/lineageweave/data/lineageweave-kg.ttl b/lineageweave/data/lineageweave-kg.ttl
index f59b50650..322a868b0 100644
--- a/lineageweave/data/lineageweave-kg.ttl
+++ b/lineageweave/data/lineageweave-kg.ttl
@@ -487,6 +487,39 @@
:evidenceInputDigest a owl:DatatypeProperty ;
rdfs:domain :ProductMention ; rdfs:range xsd:string .
+:PostVoiceClassificationAssertion a owl:Class ;
+ rdfs:label "Post voice classification assertion"@en .
+
+:OrganizationVoiceRelationshipAssertion a owl:Class ;
+ rdfs:label "Organization voice relationship assertion"@en .
+
+:voiceConceptCode a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:voiceAssertionStatus a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:voiceEvidenceDigest a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:sourceRevisionDigest a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:evidenceSpanStart a owl:DatatypeProperty ;
+ rdfs:range xsd:integer .
+
+:evidenceSpanEnd a owl:DatatypeProperty ;
+ rdfs:range xsd:integer .
+
+:validFrom a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime .
+
+:validTo a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime .
+
+:orchestratorModelReceipt a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
:Order a owl:Class ; rdfs:label "Order"@en .
:SalesContext a owl:Class ; rdfs:label "Sales context"@en .
:BusinessManagementContext a owl:Class ; rdfs:label "Business-management context"@en .
From a2a8df45b0897c26de2441ca8946fc9f42fc54f0 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 17:39:05 +0900
Subject: [PATCH 187/393] chore: pin reviewed readiness runtime
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index 5d25fa251..c1cd9505e 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/63220f263fe10dfc2e191e5e5b276c5287e2eedf.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/071c8f84e10fbd591d3915b1d5e932223cd1c640.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/63220f263fe10dfc2e191e5e5b276c5287e2eedf.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/071c8f84e10fbd591d3915b1d5e932223cd1c640.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index f5ef3b857..c9a45edd8 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `63220f263fe10dfc2e191e5e5b276c5287e2eedf`. The pin remains explicit
+commit `071c8f84e10fbd591d3915b1d5e932223cd1c640`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index 23e14092e..b0df8b74a 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "63220f263fe10dfc2e191e5e5b276c5287e2eedf"
+ expected_embedding_contract_commit = "071c8f84e10fbd591d3915b1d5e932223cd1c640"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 50057ff47d11b165ad4edd82c591160574bf953f Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 17:42:10 +0900
Subject: [PATCH 188/393] fix(config): remove embedding model selector
---
.env.example | 1 -
tests/test_documentation_hygiene.py | 3 +++
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/.env.example b/.env.example
index 06cb82d91..548b72635 100644
--- a/.env.example
+++ b/.env.example
@@ -42,7 +42,6 @@ LLM_GATEWAY_API_URL=
# Compatibility alias; LLM_GATEWAY_API_URL wins when both are set.
LLM_GATEWAY_URL=
LLM_GATEWAY_API_KEY=
-LLM_GATEWAY_EMBEDDING_MODEL=
LLM_API_GATEWAY=
LLM_API_KEY=
CALDAV_BASE_URL=
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index b0df8b74a..f3073cdbc 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -140,3 +140,6 @@ def test_embedding_bootstrap_contract_keeps_request_model_free() -> None:
)
assert "does not configure an embedding model" in adr
assert "discovered provider catalog" in adr
+ assert "LLM_GATEWAY_EMBEDDING_MODEL" not in (_ROOT / ".env.example").read_text(
+ encoding="utf-8"
+ )
From 7adb7a4a9712f2f56a7291d437225a1227debbf8 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Wed, 26 Aug 2026 01:51:35 -0700
Subject: [PATCH 189/393] feat(embeddings): deliver durable semantic backfill
runtime (#693)
* fix(ask): find related source evidence automatically
* fix(ask): preserve source publication boundary
* fix(ask): keep project evidence out of event lineage
* test(ask): lock graph and project boundaries
* fix(ask): preserve project evidence in dense windows
* fix(orchestrator): register remote embedding agent
* build(orchestrator): pin provider embedding runtime
* docs(orchestrator): separate embedding bootstrap boundary
* fix(ui): keep missing-evidence guidance accurate
* fix(ui): explain automatic evidence retry
* fix(operations): reanalyze missing facts on new evidence
* fix(runtime): restore semantic backfill contracts
* fix(ask): keep project evidence first
* fix(worker): persist operational cases before optional content enrichment
* fix(worker): preserve completed evidence on sibling retry outage
* fix(operations): use orchestrator auto model selector
* fix(compose): install orchestrator runtime dependency
* perf: bulk semantic embedding backfill
* fix(operations): address orchestrator auto deployment
* perf: prepare semantic backfill batch concurrently
* fix: isolate operations analysis from embedding batch
* Revert "fix: isolate operations analysis from embedding batch"
This reverts commit a7cda9b3434fa75a3b49aca541fb5a6a670c229b.
* Revert "perf: prepare semantic backfill batch concurrently"
This reverts commit 1d16c402d661f488801dddd648dd18f996344358.
* Revert "perf: bulk semantic embedding backfill"
This reverts commit 891e0f9021dfd71a8b0455a9e4d627b8cd41b1fb.
* fix(runtime): pin bounded provider failover
* fix(operations): invalidate stale evidence windows
* feat(embedding): add atomic cross-post bulk backfill
* build(backend): include embedding backfill operator
* perf(embedding): pack batches to advertised body limit
* build(runtime): pin advertised embedding limits
* fix(runtime): separate API and durable queue workers
* fix(runtime): restart dedicated worker after process failure
* build(runtime): pin request-deadline failover
* fix(operations): send exact request deadline
* fix(embeddings): honor orchestrator polling cadence
* fix(embeddings): wait within durable batch contract
* build(embeddings): pin true provider bulk runtime
* feat(ontology): add evidence-bound product catalog
* perf(worker): reuse authorized evidence window
* fix(products): enforce evidence-post visibility
* fix(embeddings): surface sanitized provider failures
* build(orchestrator): pin OpenAI embedding limits
* build(orchestrator): install exact embedding tokenizer
* fix(embeddings): surface oversized backfill units
* build(orchestrator): pin Rust embedding packer
* fix(products): preserve primary evidence on extraction failure
* build(orchestrator): pin durable embedding shards
* build(orchestrator): pin PyO3 embedding packer
* test(workers): run Ask settlement through dedicated consumer
* fix(orchestrator): bind configured OpenAI embedding authority
* fix(runtime): pin hardened durable embedding gateway
* fix(runtime): colocate PyO3 extension with source package
* fix(orchestrator): keep embedding selection upstream
* fix(security): audit product evidence SQL fragment
* fix(backfill): bound durable jobs by provider input contract
* fix(backfill): consume advertised input ceiling
* fix(orchestrator): propagate post session in payload
* fix(runtime): restore configured embedding capability
* chore(runtime): pin post-session embedding gateway
* fix: bind product evidence to source text
* fix(orchestrator): preserve upstream embedding discovery
* test(security): account for product evidence SQL audit
* chore(runtime): pin per-input session gateway
* fix(orchestrator): restore explicit embedding capability
* fix(orchestrator): keep model selection upstream
* fix: keep product evidence focal
* fix(orchestrator): pin embedding auto-discovery
* refactor(math): delete unused Python vector arithmetic
* fix(embeddings): bound candidate packing scan
* docs(adr): correct accelerator ownership boundary
* chore(runtime): pin hardened embedding gateway
* style(test): separate embedding transport cases
* fix(orchestrator): restore explicit embedding capability
* fix(orchestrator): restore explicit embedding capability
* docs(gap): record embedding persistence bottleneck
* fix(worker): report durable consumer progress
* chore(runtime): pin reviewed embedding failover
* fix(queue): requeue missing operations evidence
* test(compose): bind gateway auth contract
* fix(queue): wake complete posts missing case analysis
* fix(orchestrator): preserve upstream model ownership
* chore(runtime): pin structured judge failover
* chore(runtime): pin rebased embedding gateway
* fix(backfill): align current analysis selection
* chore(runtime): pin claimed embedding recovery
* fix(runtime): defer unadmitted analysis jobs
* chore: advance orchestrator embedding owner pin
* fix(database): repair interrupted concurrent indexes
* fix(database): stop rebuilding superseded search indexes
* chore: advance orchestrator embedding owner pin
* fix(products): backfill complete historical posts
* fix(embeddings): forbid caller-selected models
* chore: pin request-scoped readiness runtime
* feat: preserve overlapping voice semantic classifications (#694)
* feat(ontology): preserve overlapping voice classifications
* fix(ontology): align voice summary authorization
* fix(ui): keep voice evidence copy customer-facing
* fix(dashboard): align voice period semantics
* fix(dashboard): remove unused voice response copy
* fix: enforce voice evidence boundaries
* fix(voice): preserve active agreement evidence
* fix(ontology): validate voice assertion evidence
* fix(voice): keep source labels available
* fix(voice): reconcile source assertions at ingestion
* test: isolate future voice validity
* docs: audit Rust ownership across dashboard stack
* docs: refresh Rust boundary delivery evidence
* fix: keep voice evidence independent and unique
* fix: name voice evidence metrics precisely
* docs: complete Python compute debt inventory
* fix: preserve sourced voice memberships on reconcile
* fix: keep voice migration order unique
* fix(ui): unify dashboard loading announcement
* fix(voice): exclude demo rows from real summaries
---------
Co-authored-by: Codex
* fix(runtime): prioritize dashboard case evidence
* fix(package): sync packaged ontology vocabulary
* chore: pin reviewed readiness runtime
* fix(config): remove embedding model selector
---------
Co-authored-by: Codex
---
.env.example | 1 -
ARCHITECTURE.md | 2 +-
CHANGELOG.md | 5 +
backend/app/main.py | 156 +++--
backend/app/post_content_queue.py | 87 ++-
backend/app/post_content_worker.py | 138 ++++-
backend/app/product_semantic_ingestion.py | 83 +++
backend/app/voice_taxonomy.py | 111 ++++
backend/app/worker.py | 86 +++
backend/app/worker_health.py | 52 ++
backend/tests/test_api.py | 363 +++++++++++-
.../tests/test_product_semantic_ingestion.py | 68 +++
backend/tests/test_voice_taxonomy.py | 126 ++++
docker-compose.yml | 31 +-
docker/contextual-orchestrator/Dockerfile | 20 +-
docker/contextual-orchestrator/start.py | 18 +-
.../0030-external-llm-gateway-environment.md | 18 +-
docs/adr/0062-semantic-unit-embedding.md | 12 +-
.../0071-post-scoped-llm-session-metadata.md | 9 +-
.../0083-orchestrator-runtime-commit-pin.md | 8 +-
...98-valkey-backed-post-content-ingestion.md | 29 +
docs/adr/0122-otel-session-observability.md | 10 +-
...0166-idempotent-migration-replay-window.md | 7 +
...-externalize-local-mathematical-compute.md | 9 +-
...ative-mlx-mathematical-compute-boundary.md | 20 +-
...evidence-bound-product-semantic-catalog.md | 89 +++
...urce-preserving-voice-semantic-taxonomy.md | 63 ++
docs/adr/README.md | 2 +
...hon-mathematical-compute-boundary-audit.md | 44 +-
docs/lineage-bi-research-notes.md | 13 +-
docs/ontology/lineageweave-kg-shapes.ttl | 83 +++
docs/ontology/lineageweave-kg.ttl | 53 ++
docs/product-requirements.md | 22 +-
docs/product-technical-gap-baseline.md | 10 +-
docs/storybook-inventory.md | 4 +-
frontend/src/App.tsx | 2 +
frontend/src/api.ts | 39 ++
.../OperationsDashboard.stories.tsx | 38 ++
.../components/OperationsDashboard.test.tsx | 66 ++-
.../src/components/OperationsDashboard.tsx | 36 +-
.../ProductEvidenceList.stories.tsx | 38 ++
.../components/ProductEvidenceList.test.tsx | 18 +
.../src/components/ProductEvidenceList.tsx | 23 +
.../VoiceTaxonomySummary.stories.tsx | 16 +
.../components/VoiceTaxonomySummary.test.tsx | 19 +
.../src/components/VoiceTaxonomySummary.tsx | 36 ++
frontend/src/i18n.test.ts | 12 +
frontend/src/i18n.ts | 60 ++
lineageweave/chunking.py | 6 +-
lineageweave/data/lineageweave-kg.ttl | 546 ++++++++++++++++++
lineageweave/embedding_backfill.py | 84 ++-
lineageweave/embedding_client.py | 169 +++---
lineageweave/http_client.py | 62 +-
lineageweave/ontology.py | 9 +-
lineageweave/operations_case_analysis.py | 6 +-
lineageweave/product_semantics.py | 171 ++++++
migrations/0035_body_search_prefix.sql | 16 +-
migrations/0228_product_semantic_catalog.sql | 89 +++
.../0229_post_content_admission_deferral.sql | 7 +
migrations/0230_voice_semantic_taxonomy.sql | 200 +++++++
pyproject.toml | 3 +
scripts/backfill_post_embeddings.py | 17 +-
tests/test_backend_worker_process.py | 97 ++++
tests/test_contextual_orchestrator_start.py | 32 +-
tests/test_documentation_hygiene.py | 12 +-
tests/test_embedding_backfill.py | 70 ++-
tests/test_embedding_client.py | 146 ++---
tests/test_embedding_client_edges.py | 81 ++-
tests/test_http_client_edges.py | 49 ++
tests/test_llm_context.py | 79 +++
tests/test_math_boundary_inventory.py | 30 +
tests/test_ontology.py | 8 +
tests/test_ontology_shapes.py | 28 +
tests/test_operations_case_analysis.py | 7 +-
...orchestrator_compose_embedding_contract.py | 49 ++
tests/test_post_content_queue.py | 156 +++++
tests/test_post_content_worker.py | 270 +++++++++
tests/test_product_semantics.py | 127 ++++
tests/test_real_provider_integration.py | 50 +-
tests/test_static_sql_review_contracts.py | 2 +-
tests/test_worker_health.py | 46 ++
81 files changed, 4519 insertions(+), 460 deletions(-)
create mode 100644 backend/app/product_semantic_ingestion.py
create mode 100644 backend/app/voice_taxonomy.py
create mode 100644 backend/app/worker.py
create mode 100644 backend/app/worker_health.py
create mode 100644 backend/tests/test_product_semantic_ingestion.py
create mode 100644 backend/tests/test_voice_taxonomy.py
create mode 100644 docs/adr/0228-evidence-bound-product-semantic-catalog.md
create mode 100644 docs/adr/0230-source-preserving-voice-semantic-taxonomy.md
create mode 100644 frontend/src/components/ProductEvidenceList.stories.tsx
create mode 100644 frontend/src/components/ProductEvidenceList.test.tsx
create mode 100644 frontend/src/components/ProductEvidenceList.tsx
create mode 100644 frontend/src/components/VoiceTaxonomySummary.stories.tsx
create mode 100644 frontend/src/components/VoiceTaxonomySummary.test.tsx
create mode 100644 frontend/src/components/VoiceTaxonomySummary.tsx
create mode 100644 lineageweave/data/lineageweave-kg.ttl
create mode 100644 lineageweave/product_semantics.py
create mode 100644 migrations/0228_product_semantic_catalog.sql
create mode 100644 migrations/0229_post_content_admission_deferral.sql
create mode 100644 migrations/0230_voice_semantic_taxonomy.sql
create mode 100644 tests/test_backend_worker_process.py
create mode 100644 tests/test_orchestrator_compose_embedding_contract.py
create mode 100644 tests/test_product_semantics.py
create mode 100644 tests/test_worker_health.py
diff --git a/.env.example b/.env.example
index 06cb82d91..548b72635 100644
--- a/.env.example
+++ b/.env.example
@@ -42,7 +42,6 @@ LLM_GATEWAY_API_URL=
# Compatibility alias; LLM_GATEWAY_API_URL wins when both are set.
LLM_GATEWAY_URL=
LLM_GATEWAY_API_KEY=
-LLM_GATEWAY_EMBEDDING_MODEL=
LLM_API_GATEWAY=
LLM_API_KEY=
CALDAV_BASE_URL=
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7d04f6daa..f02bf22a4 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -60,7 +60,7 @@ flowchart LR
| `models.py` | `Record`, `Edge`, `Tree` -- source-agnostic data shapes |
| `channels.py` | Independent `[0, 1]` scoring functions |
| `chunking.py` | Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order |
-| `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` |
+| `embedding_client.py` | Provider-neutral contextual-orchestrator embedding transport and strict vector-envelope validation; no local similarity arithmetic |
| `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) |
| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the buyer sees the picture, not the base64 string; GET does not call the vision client. |
| `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8c301f56..f1bef7750 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,11 @@ All notable changes to this project are documented here. Format follows
### Changed
+- Embedding transport now validates provider envelopes without retaining
+ production-unused Python cosine and chunk-max arithmetic. Active semantic
+ retrieval remains explicitly unavailable for migration until a versioned
+ Rust owner contract is accepted; no local or database substitute is added.
+
- Ask Agent now searches posts with the same persisted semantic project key as
part of its authorized evidence window before re-analysis. Missing evidence
remains a retry state rather than a request for the reader to attach an
diff --git a/backend/app/main.py b/backend/app/main.py
index efd8494eb..6fdf6d4f3 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -42,6 +42,7 @@
ticket_created_summary,
ticket_status_changed_summary,
)
+from backend.app.voice_taxonomy import load_voice_taxonomy_summary
from backend.app.affiliate_tree_ingestion import (
fetch_affiliate_forest,
fetch_voc_evidence,
@@ -65,7 +66,6 @@
deliver_queued_analysis_run,
enqueue_pending_analysis_run,
)
-from backend.app.analysis_run_worker import run_analysis_run_worker
from backend.app.auth import CurrentAccount, get_current_account
from backend.app.config import load_settings
from backend.app.customer_hint_ingestion import resolve_customer_hint
@@ -80,10 +80,7 @@
ingest_post_entity_relationships,
)
from backend.app.five_w1h_ingestion import load_five_w1h_slots
-from backend.app.global_ask_queue import (
- enqueue_global_ask_job,
- run_global_ask_worker,
-)
+from backend.app.global_ask_queue import enqueue_global_ask_job
from backend.app.issue_ticket_ingestion import (
create_ticket,
fetch_ticket_post_id,
@@ -137,7 +134,6 @@
post_content_is_complete,
publish_post_content_event,
)
-from backend.app.post_content_worker import run_post_content_worker
from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from backend.app.post_evaluation_ingestion import (
fetch_post_evaluation,
@@ -247,69 +243,18 @@
@asynccontextmanager
async def lifespan(app: FastAPI):
- """Open one asyncpg pool and one Valkey client for the process, and
- close both on shutdown."""
+ """Open API database and Valkey clients without consuming durable jobs."""
configure_telemetry("lineageweave")
pool = None
valkey = None
- analysis_worker = None
- content_worker = None
- global_ask_worker = None
try:
settings = load_settings()
pool = await create_pool(settings.database_url)
app.state.pool = pool
valkey = create_valkey_client(settings.valkey_url)
app.state.valkey = valkey
- analysis_worker = asyncio.create_task(
- run_analysis_run_worker(
- valkey,
- pool,
- database_url=settings.database_url,
- tepp_client=configured_tepp_client(
- settings.tepp_transport_url,
- settings.tepp_api_key,
- ),
- adjudication_client=_adjudication_client(),
- )
- )
- app.state.analysis_run_worker = analysis_worker
- content_worker = asyncio.create_task(
- run_post_content_worker(
- valkey,
- pool,
- vision_factory=_vision_client,
- embedding_factory=_embedding_client,
- structure_factory=_post_structure_client,
- )
- )
- app.state.post_content_worker = content_worker
- # Late-bound lambda so tests that monkeypatch _post_chat_client reach
- # the worker too (the name resolves in module globals at call time).
- # Only this worker gets the long answer timeout; the per-post chat
- # endpoint keeps the client's interactive default.
- global_ask_worker = asyncio.create_task(
- run_global_ask_worker(
- valkey,
- pool,
- chat_factory=lambda: _post_chat_client(
- timeout=load_settings().orchestrator_answer_timeout_seconds
- ),
- embedding_factory=_embedding_client,
- )
- )
- app.state.global_ask_worker = global_ask_worker
yield
finally:
- workers = tuple(
- worker
- for worker in (analysis_worker, content_worker, global_ask_worker)
- if worker is not None
- )
- for worker in workers:
- worker.cancel()
- if workers:
- await asyncio.gather(*workers, return_exceptions=True)
try:
if pool is not None:
await pool.close()
@@ -1598,6 +1543,71 @@ async def list_posts(
}
+@app.get("/api/voice-taxonomy/summary")
+async def read_voice_taxonomy_summary(
+ date_from: date | None = None,
+ date_to: date | None = None,
+ corporate_entity_id: UUID | None = None,
+ process_unit_id: UUID | None = None,
+ team_id: UUID | None = None,
+ person_id: UUID | None = None,
+ product_catalog_id: UUID | None = None,
+ project_key: str | None = Query(default=None, max_length=200),
+ account: CurrentAccount = Depends(get_current_account),
+ pool: asyncpg.Pool = Depends(get_pool),
+) -> dict[str, Any]:
+ """Return overlapping source/derived voice counts for the selected scope."""
+ _require_post_read(account)
+ if date_from is not None and date_to is not None and date_to < date_from:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_CONTENT,
+ "Choose an end time after the start time, then review the updated scope.",
+ )
+ async with pool.acquire() as conn:
+ excluded_entity_ids: tuple[str, ...] = ()
+ if await has_real_source_context(conn, list(account.corporate_entity_ids)):
+ excluded_entity_ids = tuple(
+ sorted(await fetch_demo_corporate_entity_ids(conn))
+ )
+ summary = await load_voice_taxonomy_summary(
+ conn,
+ authorized_corporate_entity_ids=tuple(
+ str(value) for value in account.corporate_entity_ids
+ ),
+ authorized_process_unit_ids=tuple(
+ str(value) for value in account.process_unit_ids
+ ),
+ date_from=date_from,
+ date_to=date_to,
+ corporate_entity_id=str(corporate_entity_id) if corporate_entity_id else None,
+ process_unit_id=str(process_unit_id) if process_unit_id else None,
+ team_id=str(team_id) if team_id else None,
+ person_id=str(person_id) if person_id else None,
+ product_catalog_id=str(product_catalog_id) if product_catalog_id else None,
+ project_key=project_key.strip() if project_key and project_key.strip() else None,
+ excluded_corporate_entity_ids=excluded_entity_ids,
+ )
+ total = int(summary["total_eligible"])
+ raw_category_counts = summary["category_post_counts"]
+ category_counts = (
+ json.loads(raw_category_counts)
+ if isinstance(raw_category_counts, str)
+ else dict(raw_category_counts)
+ )
+ return {
+ **{key: value for key, value in summary.items() if key != "category_post_counts"},
+ "category_memberships": [
+ {
+ "voice_concept_code": code,
+ "post_count": int(count),
+ "eligible_percentage": (float(count) / total * 100.0) if total else 0.0,
+ }
+ for code, count in sorted(category_counts.items())
+ ],
+ "counts_overlap": True,
+ }
+
+
@app.get("/api/posts/{post_id}")
async def read_post(
post_id: str,
@@ -1646,6 +1656,23 @@ async def read_post(
project_evidence = await _load_project_evidence(
conn, post_id, row["source_project_code"], row["source_project_name"]
)
+ # Safe SQL: the eligibility predicate is an immutable schema fragment; post id is bound.
+ product_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ "select mention.mention_ordinal, mention.extracted_product_name, "
+ "mention.resolution_status_code, catalog.canonical_product_name, "
+ "catalog.product_level_code, mention.evidence_text, "
+ "mention.evidence_post_id, evidence_post.visibility_code, "
+ "evidence_post.corporate_entity_id, evidence_post.process_unit_id "
+ "from post_product_mention mention "
+ "left join product_catalog catalog "
+ "on catalog.product_catalog_id = mention.product_catalog_id "
+ "join source_post evidence_post "
+ "on evidence_post.post_id = mention.evidence_post_id "
+ "where mention.post_id = $1 and "
+ f"{SOURCE_POST_ELIGIBILITY_SQL.format(alias='evidence_post')} "
+ "order by mention.mention_ordinal",
+ post_id,
+ )
known_at = None
if as_of_clock is not None:
known_at = await fetch_known_at_revision(conn, post_id, as_of_clock)
@@ -1653,6 +1680,19 @@ async def read_post(
**_serialize_post(row, labels),
"post_body": row["post_body"],
"project_evidence": project_evidence,
+ "product_evidence": [
+ {
+ "mention_ordinal": item["mention_ordinal"],
+ "extracted_product_name": item["extracted_product_name"],
+ "resolution_status_code": item["resolution_status_code"],
+ "canonical_product_name": item["canonical_product_name"],
+ "product_level_code": item["product_level_code"],
+ "evidence_text": item["evidence_text"],
+ "evidence_post_id": item["evidence_post_id"],
+ }
+ for item in product_rows
+ if _can_see_post(account, item)
+ ],
}
if known_at is not None:
payload["known_at"] = known_at
diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py
index 2cc7b361d..67a169e34 100644
--- a/backend/app/post_content_queue.py
+++ b/backend/app/post_content_queue.py
@@ -208,6 +208,7 @@ async def transition_post_content_job(
end,
completed_at = case when $2 in ($4, $5) then now() else null end,
queued_at = case when $2 = $6 then now() else queued_at end,
+ next_attempt_at = null,
updated_at = now(),
last_error_code = $7,
last_error_detail = $8
@@ -236,6 +237,53 @@ async def transition_post_content_job(
return True
+async def defer_post_content_job(
+ conn: asyncpg.Connection,
+ post_id: str,
+ *,
+ expected_attempt_count: int,
+ retry_after_seconds: int,
+) -> bool:
+ """Return one unadmitted lease to queued without consuming an attempt."""
+ if type(retry_after_seconds) is not int or retry_after_seconds <= 0:
+ raise ValueError("retry_after_seconds must be a positive integer")
+ updated = await conn.execute(
+ """
+ update post_content_ingestion_job
+ set status_code = $2,
+ attempt_count = attempt_count - 1,
+ queued_at = now(),
+ next_attempt_at = now() + make_interval(secs => $5),
+ started_at = null,
+ completed_at = null,
+ updated_at = now(),
+ last_error_code = $6,
+ last_error_detail = $7
+ where post_id = $1
+ and status_code = $3
+ and attempt_count = $4
+ and attempt_count > 0
+ """,
+ post_id,
+ QUEUED,
+ RUNNING,
+ expected_attempt_count,
+ retry_after_seconds,
+ "no_viable_agent",
+ "Analysis capacity is being restored; this record will retry automatically.",
+ )
+ if not updated.endswith(" 1"):
+ return False
+ await _record_status(
+ conn,
+ post_id,
+ QUEUED,
+ failure_code="no_viable_agent",
+ detail_text="Analysis capacity is being restored; this record will retry automatically.",
+ )
+ return True
+
+
async def ensure_post_content_job(
conn: asyncpg.Connection,
post_id: str,
@@ -287,6 +335,7 @@ async def ensure_post_content_job(
status_code = $3,
attempt_count = 0,
queued_at = now(),
+ next_attempt_at = null,
started_at = null,
completed_at = null,
updated_at = now(),
@@ -370,6 +419,18 @@ async def enqueue_post_content_backfill(
or structure.decision_source_code = 'unresolved'
)
))
+ or ($3::boolean and not exists (
+ select 1
+ from operations_case_analysis analysis
+ where analysis.post_id = post.post_id
+ and analysis.source_body_sha256 = job.source_body_sha256
+ ))
+ or ($3::boolean and not exists (
+ select 1
+ from post_product_analysis analysis
+ where analysis.post_id = post.post_id
+ and analysis.source_body_sha256 = job.source_body_sha256
+ ))
)
order by post.created_at, post.post_id
limit $4
@@ -388,16 +449,28 @@ async def enqueue_post_content_backfill(
)
for row in rows:
post_id = str(row["post_id"])
+ body = str(row["post_body"] or "")
complete = await post_content_is_complete(
conn,
post_id,
require_embedding=require_embedding,
require_structure=require_structure,
)
+ if complete and require_structure:
+ complete = bool(
+ await conn.fetchval(
+ "select exists (select 1 from operations_case_analysis "
+ "where post_id = $1 and source_body_sha256 = $2) "
+ "and exists (select 1 from post_product_analysis "
+ "where post_id = $1 and source_body_sha256 = $2)",
+ post_id,
+ source_body_sha256(body),
+ )
+ )
request = await ensure_post_content_job(
conn,
post_id,
- str(row["post_body"] or ""),
+ body,
content_complete=complete,
)
if request.should_publish:
@@ -446,6 +519,7 @@ async def requeue_failed_post_content_job(
status_code = $3,
attempt_count = 0,
queued_at = now(),
+ next_attempt_at = null,
started_at = null,
completed_at = null,
updated_at = now(),
@@ -505,6 +579,7 @@ async def record_post_content_backfill_success(
status_code = $3,
started_at = null,
completed_at = now(),
+ next_attempt_at = null,
updated_at = now(),
last_error_code = null,
last_error_detail = null
@@ -538,8 +613,14 @@ async def republish_queued_post_content_jobs(
where (
status_code = $1
and (
- attempt_count = 0
- or queued_at <= now() - $2::interval
+ next_attempt_at <= now()
+ or (
+ next_attempt_at is null
+ and (
+ attempt_count = 0
+ or queued_at <= now() - $2::interval
+ )
+ )
)
)
or (
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index daf46fee3..92dfba329 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -21,18 +21,23 @@
RUNNING,
STALE_RUNNING_INTERVAL,
SUCCEEDED,
+ defer_post_content_job,
ensure_post_content_job,
post_content_is_complete,
republish_queued_post_content_jobs,
transition_post_content_job,
)
from backend.app.operations_case_ingestion import persist_operations_cases
+from backend.app.product_semantic_ingestion import (
+ persist_product_mentions,
+ resolve_product_mentions,
+)
from backend.app.post_chat_ingestion import (
find_project_sibling_post_ids,
gather_chat_sources,
)
from lineageweave.embedding_client import EmbeddingClient
-from lineageweave.http_client import HttpClientError
+from lineageweave.http_client import HttpAdmissionDeferred, HttpClientError
from lineageweave.image_content import ImageContentClient
from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata
from lineageweave.observability import record_server_failure, traced
@@ -44,6 +49,11 @@
from lineageweave.post_content_normalization import normalize_post_body
from lineageweave.post_content_persistence import persist_post_content
from lineageweave.post_structure import PostStructureClient
+from lineageweave.product_semantics import (
+ ContextualOrchestratorProductExtractionClient,
+ ProductEvidenceSource,
+ product_analysis_input_sha256,
+)
_logger = logging.getLogger(__name__)
_RECOVERY_INTERVAL_SECONDS = 30.0
@@ -106,6 +116,7 @@ def can_see(row: asyncpg.Record) -> bool:
),
source_times[source.post_id][0],
source_times[source.post_id][1],
+ source.post_body,
)
for source in sources
)
@@ -121,6 +132,7 @@ async def _persist_operations_case_analysis_if_needed(
session_id: str,
orchestrator_base_url: str,
orchestrator_api_key: str,
+ evidence_sources: tuple[OperationsEvidenceSource, ...] | None = None,
) -> None:
"""Persist cases once per exact focal body and authorized evidence window."""
context = " | ".join(
@@ -134,9 +146,10 @@ async def _persist_operations_case_analysis_if_needed(
)
if row.get(name) is not None and str(row[name]).strip()
)
- evidence_sources = await _operations_evidence_sources(
- pool, post_id, row, vision_client
- )
+ if evidence_sources is None:
+ evidence_sources = await _operations_evidence_sources(
+ pool, post_id, row, vision_client
+ )
analysis_input_digest = operations_analysis_input_sha256(
evidence_sources, context
)
@@ -169,6 +182,61 @@ async def _persist_operations_case_analysis_if_needed(
)
+async def _persist_product_analysis_if_needed(
+ pool: asyncpg.Pool,
+ post_id: str,
+ source_body_digest: str,
+ row: asyncpg.Record,
+ vision_client: ImageContentClient,
+ session_id: str,
+ orchestrator_base_url: str,
+ orchestrator_api_key: str,
+ evidence_sources: tuple[OperationsEvidenceSource, ...] | None = None,
+) -> None:
+ """Extract and persist products once per exact authorized source window."""
+ operation_sources = evidence_sources
+ if operation_sources is None:
+ operation_sources = await _operations_evidence_sources(
+ pool, post_id, row, vision_client
+ )
+ sources = tuple(
+ ProductEvidenceSource(
+ source.post_id,
+ source.source_text if source.source_text is not None else source.text,
+ )
+ for source in operation_sources
+ if source.post_id == post_id
+ )
+ input_digest = product_analysis_input_sha256(sources)
+ async with pool.acquire() as conn:
+ already_persisted = bool(
+ await conn.fetchval(
+ "select exists (select 1 from post_product_analysis "
+ "where post_id = $1 and source_body_sha256 = $2 "
+ "and analysis_input_sha256 = $3)",
+ post_id,
+ source_body_digest,
+ input_digest,
+ )
+ )
+ if already_persisted:
+ return
+ client = ContextualOrchestratorProductExtractionClient(
+ orchestrator_base_url, orchestrator_api_key
+ )
+ mentions = await asyncio.to_thread(client.extract, sources)
+ async with pool.acquire() as conn:
+ resolved = await resolve_product_mentions(conn, mentions)
+ await persist_product_mentions(
+ conn,
+ post_id,
+ source_body_digest,
+ input_digest,
+ session_id,
+ resolved,
+ )
+
+
async def _requeue_project_missing_case_jobs(
pool: asyncpg.Pool,
post_id: str,
@@ -236,11 +304,17 @@ async def _claim_job(
j.attempt_count as job_attempt_count,
j.started_at as job_started_at,
j.queued_at as job_queued_at,
+ j.next_attempt_at as job_next_attempt_at,
(
select analysis.source_body_sha256
from operations_case_analysis analysis
where analysis.post_id = p.post_id
- ) as case_analysis_source_body_sha256
+ ) as case_analysis_source_body_sha256,
+ (
+ select analysis.source_body_sha256
+ from post_product_analysis analysis
+ where analysis.post_id = p.post_id
+ ) as product_analysis_source_body_sha256
from post_content_ingestion_job j
join source_post p on p.post_id = j.post_id
where j.post_id = $1::uuid
@@ -274,7 +348,14 @@ async def _claim_job(
detail_text="post-content ingestion attempt limit was already reached",
)
return None
- if status_code == QUEUED and attempt_count > 0:
+ if status_code == QUEUED and row["job_next_attempt_at"] is not None:
+ retry_ready = await conn.fetchval(
+ "select now() >= $1::timestamptz",
+ row["job_next_attempt_at"],
+ )
+ if not retry_ready:
+ return None
+ elif status_code == QUEUED and attempt_count > 0:
retry_ready = await conn.fetchval(
"select now() >= $1::timestamptz + $2::interval",
row["job_queued_at"],
@@ -297,7 +378,15 @@ async def _claim_job(
source_body_digest,
)
)
- if content_complete and case_complete:
+ if (
+ content_complete
+ and case_complete
+ and (
+ not require_structure
+ or row["product_analysis_source_body_sha256"]
+ == source_body_digest
+ )
+ ):
return None
if status_code == RUNNING and row["job_started_at"] is not None:
stale = await conn.fetchval(
@@ -440,6 +529,9 @@ async def process_post_content_job(
with use_llm_metadata(metadata):
vision_client = vision_factory()
if settings.orchestrator_base_url and settings.orchestrator_api_key:
+ evidence_sources = await _operations_evidence_sources(
+ pool, post_id, row, vision_client
+ )
await _persist_operations_case_analysis_if_needed(
pool,
post_id,
@@ -450,7 +542,29 @@ async def process_post_content_job(
metadata["lineageweave_post_session_id"],
settings.orchestrator_base_url,
settings.orchestrator_api_key,
+ evidence_sources,
)
+ try:
+ await _persist_product_analysis_if_needed(
+ pool,
+ post_id,
+ source_body_digest,
+ row,
+ vision_client,
+ metadata["lineageweave_post_session_id"],
+ settings.orchestrator_base_url,
+ settings.orchestrator_api_key,
+ evidence_sources,
+ )
+ except HttpAdmissionDeferred:
+ raise
+ except (HttpClientError, OSError, RuntimeError, TimeoutError, ValueError) as exc:
+ _logger.error("product evidence ingestion failed for post_id=%s", post_id)
+ record_server_failure(
+ "product_semantic_ingestion",
+ exc,
+ outcome="provider_unavailable",
+ )
normalized = await asyncio.to_thread(
normalize_post_body, raw_body, vision_client
)
@@ -499,6 +613,16 @@ async def process_post_content_job(
exc,
outcome="provider_unavailable",
)
+ except HttpAdmissionDeferred as exc:
+ async with pool.acquire() as conn:
+ async with conn.transaction():
+ await defer_post_content_job(
+ conn,
+ post_id,
+ expected_attempt_count=attempt_count,
+ retry_after_seconds=exc.retry_after_seconds,
+ )
+ return
except Exception as exc: # noqa: BLE001 - durable failure is recorded for retry.
_logger.error("post content ingestion failed for post_id=%s", post_id)
outcome = (
diff --git a/backend/app/product_semantic_ingestion.py b/backend/app/product_semantic_ingestion.py
new file mode 100644
index 000000000..a2edbb110
--- /dev/null
+++ b/backend/app/product_semantic_ingestion.py
@@ -0,0 +1,83 @@
+"""Persist product mentions after fail-closed normalized catalog resolution."""
+
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+from lineageweave.product_semantics import (
+ ProductMention,
+ ResolvedProductMention,
+ normalize_product_alias,
+ resolve_product_mention,
+)
+
+
+class _Connection(Protocol):
+ def transaction(self) -> Any:
+ """Open an atomic database transaction."""
+ pass # pragma: no cover - structural protocol declaration
+
+ async def fetch(self, query: str, *args: object) -> list[Any]:
+ """Fetch parameterized rows."""
+ pass # pragma: no cover - structural protocol declaration
+
+ async def execute(self, query: str, *args: object) -> Any:
+ """Execute one parameterized statement."""
+ pass # pragma: no cover - structural protocol declaration
+
+
+async def resolve_product_mentions(
+ conn: _Connection, mentions: tuple[ProductMention, ...]
+) -> tuple[ResolvedProductMention, ...]:
+ """Resolve every mention by exact normalized alias, retaining ties."""
+ resolved: list[ResolvedProductMention] = []
+ for mention in mentions:
+ rows = await conn.fetch(
+ "select product_catalog_id from product_catalog_alias "
+ "where normalized_alias_text = $1 order by product_catalog_id",
+ normalize_product_alias(mention.extracted_product_name),
+ )
+ resolved.append(
+ resolve_product_mention(
+ mention, tuple(str(row["product_catalog_id"]) for row in rows)
+ )
+ )
+ return tuple(resolved)
+
+
+async def persist_product_mentions(
+ conn: _Connection,
+ post_id: str,
+ source_body_sha256: str,
+ analysis_input_sha256: str,
+ orchestrator_session_id: str,
+ mentions: tuple[ResolvedProductMention, ...],
+) -> None:
+ """Atomically replace one exact post's product analysis projection."""
+ async with conn.transaction():
+ await conn.execute("delete from post_product_analysis where post_id = $1", post_id)
+ await conn.execute(
+ "insert into post_product_analysis "
+ "(post_id, source_body_sha256, analysis_input_sha256, orchestrator_session_id) "
+ "values ($1, $2, $3, $4)",
+ post_id,
+ source_body_sha256,
+ analysis_input_sha256,
+ orchestrator_session_id,
+ )
+ for ordinal, resolved in enumerate(mentions):
+ mention = resolved.mention
+ await conn.execute(
+ "insert into post_product_mention "
+ "(post_id, mention_ordinal, product_catalog_id, extracted_product_name, "
+ "resolution_status_code, evidence_text, evidence_post_id, evidence_input_sha256) "
+ "values ($1, $2, $3, $4, $5, $6, $7, $8)",
+ post_id,
+ ordinal,
+ resolved.product_catalog_id,
+ mention.extracted_product_name,
+ resolved.resolution_status_code,
+ mention.evidence_text,
+ mention.evidence_post_id,
+ mention.evidence_input_sha256,
+ )
diff --git a/backend/app/voice_taxonomy.py b/backend/app/voice_taxonomy.py
new file mode 100644
index 000000000..cea93cfeb
--- /dev/null
+++ b/backend/app/voice_taxonomy.py
@@ -0,0 +1,111 @@
+"""Authorized aggregate reads for source-preserving voice assertions."""
+
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
+
+
+class _Connection(Protocol):
+ async def fetchrow(self, query: str, *args: object) -> Any:
+ """Fetch one aggregate row with bound parameters."""
+ pass # pragma: no cover - structural protocol declaration
+
+
+async def load_voice_taxonomy_summary(
+ conn: _Connection,
+ *,
+ authorized_corporate_entity_ids: tuple[str, ...],
+ authorized_process_unit_ids: tuple[str, ...],
+ date_from: Any = None,
+ date_to: Any = None,
+ corporate_entity_id: str | None = None,
+ process_unit_id: str | None = None,
+ team_id: str | None = None,
+ person_id: str | None = None,
+ product_catalog_id: str | None = None,
+ project_key: str | None = None,
+ excluded_corporate_entity_ids: tuple[str, ...] = (),
+) -> dict[str, Any]:
+ """Count overlapping voice memberships over one authorized denominator."""
+ row = await conn.fetchrow(
+ f"""
+ with eligible as (
+ select post.post_id
+ from source_post post
+ where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
+ and (post.visibility_code = 'public'
+ or (post.corporate_entity_id = any($1::uuid[])
+ and (cardinality($2::uuid[]) = 0
+ or post.process_unit_id = any($2::uuid[]))))
+ and ($3::date is null or timezone('Asia/Seoul', coalesce(post.event_occurred_at, post.created_at))::date >= $3)
+ and ($4::date is null or timezone('Asia/Seoul', coalesce(post.event_occurred_at, post.created_at))::date <= $4)
+ and ($5::uuid is null or post.corporate_entity_id = $5)
+ and ($6::uuid is null or post.process_unit_id = $6)
+ and ($7::uuid is null or exists (
+ select 1 from post_team_mention team
+ where team.post_id = post.post_id and team.team_id = $7))
+ and ($8::uuid is null or exists (
+ select 1 from post_person_mention person
+ where person.post_id = post.post_id and person.person_id = $8))
+ and ($9::uuid is null or exists (
+ select 1 from post_product_mention product
+ where product.post_id = post.post_id and product.product_catalog_id = $9))
+ and ($10::text is null or exists (
+ select 1 from post_project_mention project
+ where project.post_id = post.post_id and project.project_key = $10))
+ and not (post.corporate_entity_id = any($11::uuid[]))
+ ), memberships as (
+ select assertion.post_id, assertion.assertion_status_code,
+ assertion.voice_concept_code
+ from post_voice_classification_assertion assertion
+ join eligible on eligible.post_id = assertion.post_id
+ where (assertion.valid_from is null or assertion.valid_from <= current_timestamp)
+ and (assertion.valid_to is null or assertion.valid_to > current_timestamp)
+ ), per_post as (
+ select eligible.post_id,
+ count(distinct memberships.voice_concept_code) as membership_count,
+ bool_or(memberships.assertion_status_code = 'source') as has_source,
+ bool_or(memberships.assertion_status_code = 'derived') as has_derived
+ from eligible left join memberships on memberships.post_id = eligible.post_id
+ group by eligible.post_id
+ ), conflicts as (
+ select post_id
+ from memberships
+ group by post_id
+ having bool_or(assertion_status_code = 'source')
+ and bool_or(assertion_status_code = 'derived')
+ and array_agg(distinct voice_concept_code order by voice_concept_code)
+ filter (where assertion_status_code = 'source')
+ is distinct from
+ array_agg(distinct voice_concept_code order by voice_concept_code)
+ filter (where assertion_status_code = 'derived')
+ ), categories as (
+ select voice_concept_code, count(distinct post_id) as post_count
+ from memberships group by voice_concept_code
+ )
+ select count(*) as total_eligible,
+ count(*) filter (where membership_count = 1) as classified_unique,
+ count(*) filter (where membership_count > 1) as multi_membership,
+ count(*) filter (where coalesce(has_source, false)) as source_count,
+ count(*) filter (where coalesce(has_derived, false)) as derived_count,
+ count(*) filter (where membership_count = 0) as unavailable,
+ (select count(*) from conflicts) as disagreement,
+ coalesce((select jsonb_object_agg(voice_concept_code, post_count)
+ from categories), '{{}}'::jsonb) as category_post_counts
+ from per_post
+ """,
+ list(authorized_corporate_entity_ids),
+ list(authorized_process_unit_ids),
+ date_from,
+ date_to,
+ corporate_entity_id,
+ process_unit_id,
+ team_id,
+ person_id,
+ product_catalog_id,
+ project_key,
+ list(excluded_corporate_entity_ids),
+ )
+ return dict(row)
diff --git a/backend/app/worker.py b/backend/app/worker.py
new file mode 100644
index 000000000..fb9662f26
--- /dev/null
+++ b/backend/app/worker.py
@@ -0,0 +1,86 @@
+"""Dedicated durable-queue worker process for the Compose deployment."""
+
+from __future__ import annotations
+
+import asyncio
+
+from backend.app.activity_stream import create_valkey_client
+from backend.app.analysis_run_start import configured_tepp_client
+from backend.app.analysis_run_worker import run_analysis_run_worker
+from backend.app.config import load_settings
+from backend.app.db import create_pool
+from backend.app.global_ask_queue import run_global_ask_worker
+from backend.app.main import (
+ _adjudication_client,
+ _embedding_client,
+ _post_chat_client,
+ _post_structure_client,
+ _vision_client,
+)
+from backend.app.post_content_worker import run_post_content_worker
+from backend.app.worker_health import run_worker_heartbeat
+from lineageweave.observability import configure_telemetry, shutdown_telemetry
+
+
+async def run_worker_process() -> None:
+ """Own every durable queue consumer outside the HTTP API process."""
+ configure_telemetry("lineageweave-worker")
+ settings = load_settings()
+ pool = await create_pool(settings.database_url)
+ valkey = create_valkey_client(settings.valkey_url)
+ workers = (
+ asyncio.create_task(run_worker_heartbeat()),
+ asyncio.create_task(
+ run_analysis_run_worker(
+ valkey,
+ pool,
+ database_url=settings.database_url,
+ tepp_client=configured_tepp_client(
+ settings.tepp_transport_url,
+ settings.tepp_api_key,
+ ),
+ adjudication_client=_adjudication_client(),
+ )
+ ),
+ asyncio.create_task(
+ run_post_content_worker(
+ valkey,
+ pool,
+ vision_factory=_vision_client,
+ embedding_factory=_embedding_client,
+ structure_factory=_post_structure_client,
+ )
+ ),
+ asyncio.create_task(
+ run_global_ask_worker(
+ valkey,
+ pool,
+ chat_factory=lambda: _post_chat_client(
+ timeout=load_settings().orchestrator_answer_timeout_seconds
+ ),
+ embedding_factory=_embedding_client,
+ )
+ ),
+ )
+ try:
+ await asyncio.gather(*workers)
+ finally:
+ for worker in workers:
+ worker.cancel()
+ await asyncio.gather(*workers, return_exceptions=True)
+ try:
+ await pool.close()
+ finally:
+ try:
+ await valkey.aclose()
+ finally:
+ shutdown_telemetry()
+
+
+def main() -> None:
+ """Run the durable worker service until Compose stops the process."""
+ asyncio.run(run_worker_process())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/app/worker_health.py b/backend/app/worker_health.py
new file mode 100644
index 000000000..664f2c4e9
--- /dev/null
+++ b/backend/app/worker_health.py
@@ -0,0 +1,52 @@
+"""Progress-based health contract for the durable worker event loop."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+import time
+
+
+HEARTBEAT_PATH = Path("/tmp/lineageweave-worker-heartbeat")
+HEALTHCHECK_STATE_PATH = Path("/tmp/lineageweave-worker-healthcheck-state")
+
+
+def record_worker_heartbeat(path: Path = HEARTBEAT_PATH) -> None:
+ """Record one monotonic event-loop progress sample atomically."""
+ temporary = path.with_suffix(".tmp")
+ temporary.write_text(str(time.monotonic_ns()), encoding="ascii")
+ temporary.replace(path)
+
+
+async def run_worker_heartbeat(path: Path = HEARTBEAT_PATH) -> None:
+ """Record progress once per broker-poll interval until cancelled."""
+ while True:
+ record_worker_heartbeat(path)
+ await asyncio.sleep(1.0)
+
+
+def heartbeat_has_advanced(
+ heartbeat_path: Path = HEARTBEAT_PATH,
+ state_path: Path = HEALTHCHECK_STATE_PATH,
+) -> bool:
+ """Return whether the heartbeat advanced since the prior health probe."""
+ try:
+ current = int(heartbeat_path.read_text(encoding="ascii"))
+ except (FileNotFoundError, ValueError):
+ return False
+ previous: int | None = None
+ try:
+ previous = int(state_path.read_text(encoding="ascii"))
+ except (FileNotFoundError, ValueError):
+ pass
+ state_path.write_text(str(current), encoding="ascii")
+ return current >= 0 and (previous is None or current > previous)
+
+
+def main() -> None:
+ """Exit successfully only when the durable worker event loop progressed."""
+ raise SystemExit(0 if heartbeat_has_advanced() else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 0d10021b8..a6e9b7aaa 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -194,6 +194,19 @@
/ "migrations"
/ "0203_global_ask_authorization_scope.sql"
)
+_PRODUCT_SEMANTIC_MIGRATIONS = tuple(
+ Path(__file__).resolve().parents[2] / "migrations" / name
+ for name in (
+ "0208_operations_case_analysis.sql",
+ "0209_operations_case_evidence_source.sql",
+ "0211_operations_case_missing_fact.sql",
+ "0213_operations_external_relation_target.sql",
+ "0215_operations_case_milestone.sql",
+ "0222_operations_case_analysis_input.sql",
+ "0228_product_semantic_catalog.sql",
+ "0230_voice_semantic_taxonomy.sql",
+ )
+)
_LEFTOVER_MAP_AXIS_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
@@ -406,6 +419,8 @@ def seeded_db(demo_analyst_token):
cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text())
cur.execute(_GLOBAL_ASK_SCOPE_MIGRATION.read_text())
cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text())
+ for migration_path in _PRODUCT_SEMANTIC_MIGRATIONS:
+ cur.execute(migration_path.read_text())
cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text())
cur.execute(_CHANNEL_EVIDENCE_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text())
@@ -804,6 +819,31 @@ def client(seeded_db):
yield test_client
+@pytest.fixture
+def client_with_ask_worker(client):
+ """Run the production Ask consumer beside API tests that require settlement."""
+ from backend.app import main as main_module
+ from backend.app.config import load_settings
+ from backend.app.global_ask_queue import run_global_ask_worker
+
+ async def run_worker() -> None:
+ await run_global_ask_worker(
+ main_module.app.state.valkey,
+ main_module.app.state.pool,
+ chat_factory=lambda: main_module._post_chat_client(
+ timeout=load_settings().orchestrator_answer_timeout_seconds
+ ),
+ embedding_factory=lambda: main_module._embedding_client(),
+ )
+
+ assert client.portal is not None
+ worker = client.portal.start_task_soon(run_worker)
+ try:
+ yield client
+ finally:
+ worker.cancel()
+
+
def test_keyverse_account_resolves_exact_scope_and_role_intersection(
monkeypatch: pytest.MonkeyPatch, seeded_db, demo_analyst_token
) -> None:
@@ -1909,6 +1949,317 @@ def test_post_detail_uses_lookup_labels_not_raw_codes(client, demo_analyst_token
assert body["visibility_label"] == "Public"
+def test_post_detail_returns_authorized_product_evidence(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """The post response exposes only its persisted evidence-bound product link."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "insert into product_catalog "
+ "(canonical_product_name, product_level_code, product_catalog_code) "
+ "values (%s, %s, %s) returning product_catalog_id",
+ ("Synthetic Model Q", "product_model", "SYNTH-Q"),
+ )
+ catalog_id = cur.fetchone()[0]
+ cur.execute(
+ "insert into post_product_analysis "
+ "(post_id, source_body_sha256, analysis_input_sha256, orchestrator_session_id) "
+ "values (%s, %s, %s, %s)",
+ (seeded_db["public_post_id"], "a" * 64, "b" * 64, "session-a"),
+ )
+ cur.execute(
+ "insert into post_product_mention "
+ "(post_id, mention_ordinal, product_catalog_id, extracted_product_name, "
+ "resolution_status_code, evidence_text, evidence_post_id, evidence_input_sha256) "
+ "values (%s, 0, %s, %s, 'unique', %s, %s, %s)",
+ (
+ seeded_db["public_post_id"], catalog_id, "Synthetic Model Q",
+ "Synthetic evidence", seeded_db["public_post_id"], "c" * 64,
+ ),
+ )
+ cur.execute(
+ "insert into post_product_mention "
+ "(post_id, mention_ordinal, extracted_product_name, "
+ "resolution_status_code, evidence_text, evidence_post_id, "
+ "evidence_input_sha256) "
+ "values (%s, 1, %s, 'missing', %s, %s, %s)",
+ (
+ seeded_db["public_post_id"],
+ "Hidden Synthetic Model",
+ "Hidden synthetic evidence",
+ seeded_db["other_private_post_id"],
+ "d" * 64,
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ response = client.get(
+ f"/api/posts/{seeded_db['public_post_id']}",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200
+ assert response.json()["product_evidence"] == [{
+ "mention_ordinal": 0,
+ "extracted_product_name": "Synthetic Model Q",
+ "resolution_status_code": "unique",
+ "canonical_product_name": "Synthetic Model Q",
+ "product_level_code": "product_model",
+ "evidence_text": "Synthetic evidence",
+ "evidence_post_id": seeded_db["public_post_id"],
+ }]
+
+
+def test_voice_taxonomy_summary_uses_visible_post_denominator(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """Counts include visible unavailable posts and disclose overlap semantics."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute("delete from post_voice_classification_assertion")
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_sha256, "
+ "source_revision_digest) select post_id, 'voc', 'source', repeat('a', 64), "
+ "repeat('b', 64) from source_post"
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ response = client.get(
+ "/api/voice-taxonomy/summary",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200, response.text
+ payload = response.json()
+ assert payload["total_eligible"] == 4
+ assert payload["source_count"] == 4
+ assert payload["counts_overlap"] is True
+ assert payload["category_memberships"] == [{
+ "voice_concept_code": "voc",
+ "post_count": 4,
+ "eligible_percentage": 100.0,
+ }]
+ assert "category_post_counts" not in payload
+
+
+def test_voice_source_ingestion_is_available_for_future_business_event(
+ seeded_db,
+) -> None:
+ """Ingestion records a source label immediately, not at event time."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "delete from post_voice_classification_assertion where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "update source_post set post_body = post_body, "
+ "event_occurred_at = '2999-01-01T00:00:00Z' where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "select classification_assertion_id, valid_from "
+ "from post_voice_classification_assertion "
+ "where post_id = %s and assertion_status_code = 'source' "
+ "and voice_concept_code = 'voc'",
+ (seeded_db["public_post_id"],),
+ )
+ first_assertion_id, valid_from = cur.fetchone()
+ assert valid_from is None
+ cur.execute(
+ "update source_post set post_body = post_body || ' revised' "
+ "where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "update source_post set post_body = post_body where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "select count(*), count(*) filter (where valid_to is null), "
+ "count(*) filter (where classification_assertion_id = %s "
+ "and valid_to is not null), "
+ "max(supersedes_assertion_id::text) filter (where valid_to is null) "
+ "from post_voice_classification_assertion where post_id = %s "
+ "and assertion_status_code = 'source'",
+ (first_assertion_id, seeded_db["public_post_id"]),
+ )
+ assert cur.fetchone() == (
+ 2,
+ 1,
+ 1,
+ str(first_assertion_id),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def test_derived_voice_assertion_requires_model_receipt(seeded_db) -> None:
+ """A derived classification cannot persist without its model receipt."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur, pytest.raises(psycopg2.errors.CheckViolation):
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_span_start, "
+ "evidence_span_end, evidence_sha256, source_revision_digest) "
+ "values (%s, 'voc', 'derived', 0, 1, repeat('a', 64), repeat('b', 64))",
+ (seeded_db["public_post_id"],),
+ )
+ finally:
+ conn.close()
+
+
+def test_voice_source_reconcile_preserves_other_sourced_memberships(seeded_db) -> None:
+ """A body revision supersedes its source label without erasing another source."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_sha256, "
+ "source_revision_digest) values (%s, 'vom', 'source', repeat('a', 64), "
+ "repeat('b', 64))",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "update source_post set post_body = post_body || ' revised' where post_id = %s",
+ (seeded_db["public_post_id"],),
+ )
+ cur.execute(
+ "select voice_concept_code from post_voice_classification_assertion "
+ "where post_id = %s and assertion_status_code = 'source' "
+ "and valid_to is null order by voice_concept_code",
+ (seeded_db["public_post_id"],),
+ )
+ assert [row[0] for row in cur.fetchall()] == ["voc", "vom"]
+ finally:
+ conn.close()
+
+
+def test_voice_assertion_rejects_duplicate_open_scope(seeded_db) -> None:
+ """One post, status, and concept cannot have two current assertions."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute("delete from post_voice_classification_assertion")
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_sha256, "
+ "source_revision_digest) values (%s, 'voc', 'source', repeat('a', 64), "
+ "repeat('b', 64))",
+ (seeded_db["public_post_id"],),
+ )
+ with pytest.raises(psycopg2.errors.UniqueViolation):
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, evidence_sha256, "
+ "source_revision_digest) values (%s, 'voc', 'source', repeat('c', 64), "
+ "repeat('d', 64))",
+ (seeded_db["public_post_id"],),
+ )
+ finally:
+ conn.close()
+
+
+def test_voice_taxonomy_matching_multi_membership_is_not_a_disagreement(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """Matching source and derived concept sets remain agreement evidence."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute("delete from post_voice_classification_assertion")
+ for status_code in ("source", "derived"):
+ for concept_code in ("voc", "vom"):
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, "
+ "evidence_span_start, evidence_span_end, evidence_sha256, "
+ "source_revision_digest, orchestrator_model_receipt) "
+ "values (%s, %s, %s, %s, %s, repeat(%s, 64), repeat(%s, 64), %s)",
+ (
+ seeded_db["public_post_id"],
+ concept_code,
+ status_code,
+ 0 if status_code == "derived" else None,
+ 1 if status_code == "derived" else None,
+ "a" if concept_code == "voc" else "b",
+ "c" if concept_code == "voc" else "d",
+ "synthetic-receipt" if status_code == "derived" else None,
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ response = client.get(
+ "/api/voice-taxonomy/summary",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200, response.text
+ payload = response.json()
+ assert payload["multi_membership"] == 1
+ assert payload["disagreement"] == 0
+
+
+def test_voice_taxonomy_excludes_assertions_before_their_validity_window(
+ client, demo_analyst_token, seeded_db
+) -> None:
+ """A future assertion is unavailable until its recorded validity begins."""
+ conn = psycopg2.connect(seeded_db["dsn"])
+ try:
+ with conn.cursor() as cur:
+ cur.execute("delete from post_voice_classification_assertion")
+ cur.execute(
+ "insert into post_voice_classification_assertion "
+ "(post_id, voice_concept_code, assertion_status_code, "
+ "evidence_sha256, source_revision_digest, valid_from) "
+ "values (%s, 'voc', 'source', repeat('a', 64), repeat('b', 64), "
+ "'2999-01-01T00:00:00Z')",
+ (seeded_db["public_post_id"],),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ response = client.get(
+ "/api/voice-taxonomy/summary",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200, response.text
+ payload = response.json()
+ assert payload["source_count"] == 0
+ assert payload["unavailable"] == payload["total_eligible"]
+
+
+def test_voice_taxonomy_summary_rejects_reversed_period(
+ client, demo_analyst_token
+) -> None:
+ response = client.get(
+ "/api/voice-taxonomy/summary?date_from=2026-02-01&date_to=2026-01-01",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 422
+ assert "Choose an end time" in response.json()["detail"]
+
+
+def test_voice_taxonomy_summary_accepts_one_calendar_day(
+ client, demo_analyst_token
+) -> None:
+ response = client.get(
+ "/api/voice-taxonomy/summary?date_from=2026-01-01&date_to=2026-01-01",
+ headers={"Authorization": f"Bearer {demo_analyst_token}"},
+ )
+ assert response.status_code == 200
+
+
def test_post_detail_exposes_explicit_and_semantic_project_evidence(
client, demo_analyst_token, seeded_db
) -> None:
@@ -3974,7 +4325,7 @@ def answer(self, question: str, sources) -> object:
def test_global_ask_provider_error_does_not_leak_raw_error(
- client, demo_analyst_token, seeded_db, monkeypatch
+ client_with_ask_worker, demo_analyst_token, seeded_db, monkeypatch
) -> None:
"""The cross-post Ask boundary settles a provider failure with a
stable message, not the worker's raw exception text (ADR 0123).
@@ -4002,7 +4353,7 @@ async def _source(*_args, **_kwargs):
monkeypatch.setattr("backend.app.main._post_chat_client", lambda **_kwargs: _FailingAskClient())
headers = {"Authorization": f"Bearer {demo_analyst_token}"}
- submitted = client.post(
+ submitted = client_with_ask_worker.post(
"/api/ask",
json={"question": "What happened in this global failure case?"},
headers=headers,
@@ -4013,7 +4364,7 @@ async def _source(*_args, **_kwargs):
deadline = _time.monotonic() + 30
body: dict = {}
while _time.monotonic() < deadline:
- polled = client.get(f"/api/ask/jobs/{job_id}", headers=headers)
+ polled = client_with_ask_worker.get(f"/api/ask/jobs/{job_id}", headers=headers)
assert polled.status_code == 200
body = polled.json()
if body["job_status_code"] in ("succeeded", "failed"):
@@ -5127,7 +5478,7 @@ def test_ask_requires_authentication(client) -> None:
def test_ask_queues_a_job_and_polls_it_to_a_settled_answer(
- client, demo_analyst_token, seeded_db, monkeypatch
+ client_with_ask_worker, demo_analyst_token, seeded_db, monkeypatch
) -> None:
"""Submission returns 202 immediately; the worker settles the job.
@@ -5162,7 +5513,7 @@ async def _source(*_args, **_kwargs):
"backend.app.global_ask_queue.compute_global_ask_answer", _fake_compute_answer
)
headers = {"Authorization": f"Bearer {demo_analyst_token}"}
- submitted = client.post(
+ submitted = client_with_ask_worker.post(
"/api/ask", json={"question": "What happened with the public post?"}, headers=headers
)
assert submitted.status_code == 202
@@ -5172,7 +5523,7 @@ async def _source(*_args, **_kwargs):
deadline = _time.monotonic() + 30
body: dict = {}
while _time.monotonic() < deadline:
- polled = client.get(f"/api/ask/jobs/{job_id}", headers=headers)
+ polled = client_with_ask_worker.get(f"/api/ask/jobs/{job_id}", headers=headers)
assert polled.status_code == 200
body = polled.json()
if body["job_status_code"] in ("succeeded", "failed"):
diff --git a/backend/tests/test_product_semantic_ingestion.py b/backend/tests/test_product_semantic_ingestion.py
new file mode 100644
index 000000000..dcbee9d29
--- /dev/null
+++ b/backend/tests/test_product_semantic_ingestion.py
@@ -0,0 +1,68 @@
+"""Tests for normalized product semantic persistence."""
+
+from contextlib import asynccontextmanager
+import asyncio
+
+from backend.app.product_semantic_ingestion import (
+ persist_product_mentions,
+ resolve_product_mentions,
+)
+from lineageweave.product_semantics import ProductMention, ResolvedProductMention
+
+
+class _Connection:
+ def __init__(self, rows: list[dict[str, str]] | None = None) -> None:
+ self.rows = rows or []
+ self.calls: list[tuple[str, tuple[object, ...]]] = []
+
+ @asynccontextmanager
+ async def transaction(self):
+ yield
+
+ async def fetch(self, query: str, *args: object) -> list[dict[str, str]]:
+ self.calls.append((query, args))
+ return self.rows
+
+ async def execute(self, query: str, *args: object) -> None:
+ self.calls.append((query, args))
+
+
+def test_resolve_product_mentions_uses_parameterized_normalized_alias() -> None:
+ connection = _Connection([{"product_catalog_id": "catalog-a"}])
+ mention = ProductMention(" PRODUCT Q ", "PRODUCT", "post-a", "a" * 64)
+ resolved = asyncio.run(resolve_product_mentions(connection, (mention,)))
+ assert resolved[0].product_catalog_id == "catalog-a"
+ assert connection.calls[0][1] == ("product q",)
+
+
+def test_resolve_product_mentions_preserves_catalog_tie() -> None:
+ connection = _Connection(
+ [{"product_catalog_id": "catalog-a"}, {"product_catalog_id": "catalog-b"}]
+ )
+ mention = ProductMention("Product Q", "Product Q", "post-a", "a" * 64)
+ resolved = asyncio.run(resolve_product_mentions(connection, (mention,)))
+ assert resolved[0].resolution_status_code == "tie"
+ assert resolved[0].product_catalog_id is None
+
+
+def test_persist_product_mentions_replaces_exact_projection() -> None:
+ connection = _Connection()
+ mention = ProductMention("Product Q", "Product Q", "post-a", "a" * 64)
+ resolved = ResolvedProductMention(mention, "missing", None)
+ asyncio.run(
+ persist_product_mentions(
+ connection, "post-a", "b" * 64, "c" * 64, "session-a", (resolved,)
+ )
+ )
+ assert len(connection.calls) == 3
+ assert connection.calls[0][1] == ("post-a",)
+ assert connection.calls[2][1] == (
+ "post-a",
+ 0,
+ None,
+ "Product Q",
+ "missing",
+ "Product Q",
+ "post-a",
+ "a" * 64,
+ )
diff --git a/backend/tests/test_voice_taxonomy.py b/backend/tests/test_voice_taxonomy.py
new file mode 100644
index 000000000..0aae97afa
--- /dev/null
+++ b/backend/tests/test_voice_taxonomy.py
@@ -0,0 +1,126 @@
+"""Tests for authorized voice-taxonomy aggregate queries."""
+
+import asyncio
+
+from backend.app import main
+from backend.app.auth import CurrentAccount
+from backend.app.voice_taxonomy import load_voice_taxonomy_summary
+
+
+class _Connection:
+ def __init__(self) -> None:
+ self.args: tuple[object, ...] = ()
+
+ async def fetchrow(self, query: str, *args: object):
+ assert "post_product_mention" in query
+ assert "post_project_mention" in query
+ assert "post.visibility_code = 'public'" in query
+ assert "cardinality($2::uuid[]) = 0" in query
+ assert "not (post.corporate_entity_id = any($11::uuid[]))" in query
+ assert "source_deleted_flag" in query
+ self.args = args
+ return {
+ "total_eligible": 4,
+ "classified_unique": 1,
+ "multi_membership": 1,
+ "source_count": 2,
+ "derived_count": 1,
+ "unavailable": 2,
+ "disagreement": 1,
+ "category_post_counts": {"voc": 2, "vom": 1},
+ }
+
+
+def test_voice_summary_binds_authorization_and_every_filter() -> None:
+ connection = _Connection()
+ summary = asyncio.run(
+ load_voice_taxonomy_summary(
+ connection,
+ authorized_corporate_entity_ids=("corp-a",),
+ authorized_process_unit_ids=("pu-a",),
+ date_from="from",
+ date_to="to",
+ corporate_entity_id="corp-filter",
+ process_unit_id="pu-filter",
+ team_id="team-filter",
+ person_id="person-filter",
+ product_catalog_id="product-filter",
+ project_key="project-filter",
+ excluded_corporate_entity_ids=("demo-corp",),
+ )
+ )
+ assert summary["total_eligible"] == 4
+ assert connection.args == (
+ ["corp-a"], ["pu-a"], "from", "to", "corp-filter", "pu-filter",
+ "team-filter", "person-filter", "product-filter", "project-filter",
+ ["demo-corp"],
+ )
+
+
+def test_voice_summary_excludes_demo_entities_when_real_context_exists(monkeypatch) -> None:
+ """A real-data account never mixes synthetic seed rows into its denominator."""
+ captured: dict[str, object] = {}
+
+ class Acquire:
+ async def __aenter__(self):
+ return object()
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ class Pool:
+ def acquire(self) -> Acquire:
+ return Acquire()
+
+ async def has_real(_conn: object, entity_ids: list[str]) -> bool:
+ assert entity_ids == ["00000000-0000-0000-0000-000000000001"]
+ return True
+
+ async def demo_ids(_conn: object) -> set[str]:
+ return {"00000000-0000-0000-0000-000000000099"}
+
+ async def load(_conn: object, **kwargs: object) -> dict[str, object]:
+ captured.update(kwargs)
+ return {
+ "total_eligible": 0,
+ "classified_unique": 0,
+ "multi_membership": 0,
+ "source_count": 0,
+ "derived_count": 0,
+ "unavailable": 0,
+ "disagreement": 0,
+ "category_post_counts": {},
+ }
+
+ monkeypatch.setattr(main, "has_real_source_context", has_real)
+ monkeypatch.setattr(main, "fetch_demo_corporate_entity_ids", demo_ids)
+ monkeypatch.setattr(main, "load_voice_taxonomy_summary", load)
+ account = CurrentAccount(
+ user_account_id="00000000-0000-0000-0000-000000000010",
+ external_subject_id="synthetic-subject",
+ display_name="Synthetic reader",
+ preferred_locale="en",
+ corporate_entity_ids=frozenset({"00000000-0000-0000-0000-000000000001"}),
+ process_unit_ids=frozenset(),
+ permission_codes=frozenset({"post_read"}),
+ )
+
+ result = asyncio.run(
+ main.read_voice_taxonomy_summary(
+ date_from=None,
+ date_to=None,
+ corporate_entity_id=None,
+ process_unit_id=None,
+ team_id=None,
+ person_id=None,
+ product_catalog_id=None,
+ project_key=None,
+ account=account,
+ pool=Pool(),
+ )
+ )
+
+ assert result["total_eligible"] == 0
+ assert captured["excluded_corporate_entity_ids"] == (
+ "00000000-0000-0000-0000-000000000099",
+ )
diff --git a/docker-compose.yml b/docker-compose.yml
index 6729174f6..4faee915f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -119,11 +119,15 @@ services:
# explicit bounded 8 MiB limit rather than an unbounded request size.
CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES: ${CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES:-8388608}
CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS: ${CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS:-host.docker.internal}
+ BATCH_JOB_REGISTRY_VALKEY_URL: redis://valkey:6379/1
OTEL_SERVICE_NAME: ${OTEL_ORCHESTRATOR_SERVICE_NAME:-contextual-orchestrator}
# Do not set OTEL_EXPORTER_OTLP_ENDPOINT here. An empty
# ${OTEL_EXPORTER_OTLP_ENDPOINT:-} interpolation would wipe a value from
# env_file (${HOME}/.env). Export stays opt-in from that file or the host.
command: ["python", "/app/start.py"]
+ depends_on:
+ valkey:
+ condition: service_healthy
ports:
- "${ORCHESTRATOR_PORT:-18000}:8000"
healthcheck:
@@ -142,7 +146,7 @@ services:
build:
context: .
dockerfile: backend/Dockerfile
- environment:
+ environment: &backend-environment
DATABASE_URL: postgresql://${POSTGRES_USER:-lineageweave}:${POSTGRES_PASSWORD:-lineageweave_dev_only}@postgres:5432/${POSTGRES_DB:-lineageweave}
# Internal DNS name for JWKS fetches (always reachable from inside the
# compose network); KEYCLOAK_ISSUER is the *external*, host-published
@@ -201,6 +205,31 @@ services:
searxng:
condition: service_healthy
+ backend-worker:
+ build:
+ context: .
+ dockerfile: backend/Dockerfile
+ command: ["python", "-m", "backend.app.worker"]
+ restart: unless-stopped
+ environment: *backend-environment
+ depends_on:
+ postgres:
+ condition: service_healthy
+ database_migration:
+ condition: service_completed_successfully
+ orchestrator:
+ condition: service_healthy
+ valkey:
+ condition: service_healthy
+ searxng:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "python", "-m", "backend.app.worker_health"]
+ interval: 10s
+ timeout: 3s
+ retries: 3
+ start_period: 5s
+
frontend:
build:
context: ./frontend
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index d2ab83d29..c1cd9505e 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -1,3 +1,15 @@
+FROM python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf AS token-builder
+RUN apt-get update && apt-get install -y --no-install-recommends curl build-essential ca-certificates \
+ && rm -rf /var/lib/apt/lists/* \
+ && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
+ sh -s -- -y --profile minimal --default-toolchain 1.97.1
+ENV PATH=/root/.cargo/bin:$PATH
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/071c8f84e10fbd591d3915b1d5e932223cd1c640.tar.gz /tmp/contextual-orchestrator.tar.gz
+RUN mkdir /tmp/contextual-orchestrator \
+ && tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
+ && python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
+ && maturin build --locked --release --manifest-path /tmp/contextual-orchestrator/rust/token_counter/Cargo.toml --out /tmp/token-wheels
+
FROM python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf
WORKDIR /app
@@ -5,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/d9c62be9feea24fdaeb8453f3c72f2c2b0237143.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/071c8f84e10fbd591d3915b1d5e932223cd1c640.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
@@ -16,8 +28,14 @@ RUN mkdir /tmp/contextual-orchestrator \
'opentelemetry-api>=1.30.0' \
'opentelemetry-sdk>=1.30.0' \
'opentelemetry-exporter-otlp-proto-http>=1.30.0' \
+ 'redis>=5.0' \
&& useradd --uid 10001 --no-create-home orchestrator
+COPY --from=token-builder /tmp/token-wheels /tmp/token-wheels
+RUN pip install --no-cache-dir /tmp/token-wheels/*.whl \
+ && cp /usr/local/lib/python3.12/site-packages/contextual_orchestrator/_token_packer*.so /app/contextual_orchestrator/ \
+ && rm -rf /tmp/token-wheels
+
COPY agents.json /app/agents.json
COPY start.py /app/start.py
diff --git a/docker/contextual-orchestrator/start.py b/docker/contextual-orchestrator/start.py
index 07fa48cc1..5cf29e136 100644
--- a/docker/contextual-orchestrator/start.py
+++ b/docker/contextual-orchestrator/start.py
@@ -48,7 +48,7 @@ def main() -> None:
raise SystemExit("LLM_GATEWAY_API_URL or LLM_GATEWAY_URL is required to start the gateway")
if not provider_url.rstrip("/").endswith("/v1"):
provider_url = provider_url.rstrip("/") + "/v1"
- embedding_model = os.environ.pop("LLM_GATEWAY_EMBEDDING_MODEL", "").strip()
+ batch_registry_url = os.environ.pop("BATCH_JOB_REGISTRY_VALKEY_URL", "").strip()
raw_limit = os.environ.pop("LLM_GATEWAY_MAX_OUTPUT_TOKENS", "4096").strip()
try:
max_output_tokens = int(raw_limit)
@@ -69,26 +69,17 @@ def main() -> None:
agent["base_url"] = provider_url
agent["credential_key"] = "LLM_GATEWAY_API_KEY"
agent.setdefault("provider_protocol", "auto")
- if embedding_model:
- agents["agents"].append(
- {
- "id": "gateway_embedding_agent",
- "model": embedding_model,
- "provider_protocol": "auto",
- "base_url": provider_url,
- "credential_key": "LLM_GATEWAY_API_KEY",
- "tags": ["embedding"],
- "priority": 1,
- }
- )
agents_path.write_text(json.dumps(agents), encoding="utf-8")
from contextual_orchestrator.credentials import register_credential
register_credential("LLM_GATEWAY_API_KEY", gateway_key)
+ if batch_registry_url:
+ register_credential("batch_job_registry_valkey_url", batch_registry_url)
for credential_name, credential_value in provider_credentials.items():
register_credential(credential_name, credential_value)
del gateway_key
+ del batch_registry_url
del provider_credentials
sys.argv = [
"contextual_orchestrator",
@@ -109,7 +100,6 @@ def main() -> None:
str(max_body_bytes),
]
del provider_url
- del embedding_model
del auth_token
from contextual_orchestrator.__main__ import main as serve
diff --git a/docs/adr/0030-external-llm-gateway-environment.md b/docs/adr/0030-external-llm-gateway-environment.md
index 58910df4b..890c65586 100644
--- a/docs/adr/0030-external-llm-gateway-environment.md
+++ b/docs/adr/0030-external-llm-gateway-environment.md
@@ -75,17 +75,13 @@ must never be returned through a buyer-facing API or persisted failure detail.
When they are blank, contextual-orchestrator resolves the registered agent
model, so a local or provider-specific model name cannot leak into this
application or be assumed available on an external gateway.
-- LineageWeave embedding requests do not select a model: every batch omits
- `model`. At the Compose process boundary an operator-supplied
- `LLM_GATEWAY_EMBEDDING_MODEL` may register one explicit remote agent tagged
- `embedding` in contextual-orchestrator, using the same provider URL and
- credential handle as the gateway. The bootstrap removes that environment
- value before serving; application code never reads it, sends it in a request,
- or calls the provider directly. contextual-orchestrator returns the selected
- identity on submission and polling responses. LineageWeave binds that
- identity for later batches and persists it with every vector. A missing or
- changed identity, or an incomplete vector batch, fails closed and cannot make
- post content complete.
+- LineageWeave does not configure an embedding model. Its first batch request
+ omits `model`; contextual-orchestrator selects a provider-neutral embedding
+ model from its discovered provider catalog and returns that identity on
+ submission and polling responses. LineageWeave binds that identity for later
+ batches and persists it with every vector. A missing or changed identity, or
+ an incomplete vector batch, fails closed and cannot make post content
+ complete.
- `LLM_API_KEY`, `LLM_API_GATEWAY`, and `LLM_GATEWAY_URL` are compatibility
aliases only; `LLM_GATEWAY_API_KEY` and `LLM_GATEWAY_API_URL` are the
canonical names for
diff --git a/docs/adr/0062-semantic-unit-embedding.md b/docs/adr/0062-semantic-unit-embedding.md
index 3763caed7..3cda12895 100644
--- a/docs/adr/0062-semantic-unit-embedding.md
+++ b/docs/adr/0062-semantic-unit-embedding.md
@@ -1,6 +1,6 @@
# ADR 0062: Embed paragraph and meaning-identifiable content units
-- Status: Accepted
+- Status: Accepted; arithmetic amended by ADR 0208
- Date: 2026-08-19
## Context
@@ -21,10 +21,7 @@ post whenever the source contains more than one unit:
- sentence boundaries when the caller explicitly selects the finer unit;
- conversation-turn boundaries for sender/receiver shaped content.
-`chunked_max_similarity` embeds every selected unit through the
-contextual-orchestrator embedding channel and max-pools unit-pair similarity.
-If a source produces zero or one unit, it falls back to one whole-text
-embedding because there is no meaningful pairwise chunk comparison. Persisted
+Persisted
`post_content_unit` rows are the provenance anchor for unit-level embeddings;
`post_content_embedding` and its value rows retain model and dimension
identity. The model identity is selected and returned by
@@ -35,6 +32,11 @@ provider-specific environment variable.
No local heuristic vector or whole-document replacement is allowed when the
configured embedding channel is unavailable.
+ADR 0208 removes the production-unused local pairwise cosine/max-pooling
+experiment. A future similarity score requires a versioned Rust owner envelope;
+LineageWeave retains semantic-unit selection, authorization, provenance, and
+strict envelope validation only.
+
## Consequences
- Ontology and semantic search can attribute a match to the specific content
diff --git a/docs/adr/0071-post-scoped-llm-session-metadata.md b/docs/adr/0071-post-scoped-llm-session-metadata.md
index 44aa14e4d..a4fccc6a1 100644
--- a/docs/adr/0071-post-scoped-llm-session-metadata.md
+++ b/docs/adr/0071-post-scoped-llm-session-metadata.md
@@ -7,9 +7,10 @@
Every contextual-orchestrator request made about one post carries the same
deterministic `lineageweave_post_session_id` in the existing OpenAI-compatible
-`metadata` object. The ID is derived from `post_id` with a LineageWeave-only
-UUID namespace; it is not a database key and does not require a
-`user_account + post_id` table.
+`metadata` object and, for POST requests, as the top-level orchestrator
+`session_id`. The correlation header defined by ADR 0122 carries that same
+value. The ID is derived from `post_id` with a LineageWeave-only UUID namespace;
+it is not a database key and does not require a `user_account + post_id` table.
The same metadata object carries non-body provenance hints when available:
PU, author account ID, corporate-entity code, and source author/company,
@@ -30,3 +31,5 @@ be implemented by runtime monkey patching or by reusing a workflow run ID.
not an implicit conversation-memory store.
- Posts without a post scope, such as global Ask Agent, do not receive a fake
post session ID.
+- Provider-neutral payloads sent to services other than contextual-orchestrator
+ do not receive the orchestrator-only top-level `session_id` field.
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index bf0937b5b..c9a45edd8 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `d9c62be9feea24fdaeb8453f3c72f2c2b0237143`. The pin remains explicit
+commit `071c8f84e10fbd591d3915b1d5e932223cd1c640`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
@@ -33,8 +33,10 @@ The runtime contract is:
reconciliation prompt; independent VISION worker evidence is retained instead.
- A provider 4xx is reported as a failed orchestration attempt, never as a
successful empty semantic result.
-- An empty seed model is expanded from the configured gateway `/v1/models`
- endpoint; embedding-only rows are not added to the chat agent pool.
+- An empty seed model is expanded from configured provider discovery endpoints;
+ provider-declared embedding rows enter the embedding pool but never a chat role.
+- Runtime discovery activates provider-declared chat and embedding capabilities.
+ LineageWeave does not configure or infer an embedding provider/model pair.
- A batch embedding request may omit `model`; contextual-orchestrator selects
an embedding-capable model and returns its identity for subsequent batches.
- A blank embedding input fails before provider selection; it is never sent as
diff --git a/docs/adr/0098-valkey-backed-post-content-ingestion.md b/docs/adr/0098-valkey-backed-post-content-ingestion.md
index b106cb287..8ba786d29 100644
--- a/docs/adr/0098-valkey-backed-post-content-ingestion.md
+++ b/docs/adr/0098-valkey-backed-post-content-ingestion.md
@@ -101,10 +101,39 @@ The producer applies `SOURCE_POST_ELIGIBILITY_SQL`, locks source rows with
`SKIP LOCKED`, selects only new or incomplete-succeeded jobs, rechecks the
shared completeness predicate, and records the existing job state in
PostgreSQL. Repeated calls therefore do not reset active or terminal work.
+When contextual-orchestrator evidence is required, an otherwise complete
+successful job with no `operations_case_analysis` row is also incomplete and
+eligible for the same bounded requeue. This lets records completed before the
+operations extractor was deployed enter that extractor without a synchronous
+provider call or a second queue.
If Valkey is unavailable, the response reports `recovery_pending` and the
committed queued rows are republished by the existing recovery sweep. Direct
provider calls are not a substitute for the worker queue.
+## Provider admission deferral (2026-08-26)
+
+Contextual-orchestrator may return its typed `no_viable_agent` response before
+any provider inference is admitted. It supplies the same positive delay in the
+standard `Retry-After` header and its bounded error contract. This outcome is
+queue admission evidence, not a provider attempt or a negative analysis.
+
+The owning worker therefore uses a fenced PostgreSQL transition from the exact
+running lease back to queued, reverses only that lease's claim increment, and
+stores `next_attempt_at` from the orchestrator's exact delay. The post identity,
+body digest, post-scoped session, and existing evidence remain unchanged. A
+stale worker cannot defer a newer lease. Recovery publishes the row only after
+`next_attempt_at`; other transport, provider, validation, and persistence
+failures retain the existing three-attempt accounting. Raw upstream error text,
+agent identity, prompt, and response are neither stored nor shown to a reader.
+
+Operations-case analysis is the Dashboard acceptance channel and runs before
+optional product extraction inside a claimed job. Each channel commits through
+its own existing persistence transaction while retaining the same post-scoped
+session and exact body digest. A later product extraction failure therefore
+cannot erase an already committed operations case, and product latency cannot
+delay admission of the case request. This is execution isolation, not a new
+queue or a change to either channel's evidence contract.
+
### Operational timeout for structure adjudication
The contextual-orchestrator structure adjudication request uses a 600-second client timeout by default. Structure inference is an accuracy-critical, structured multi-agent operation rather than a user-facing synchronous request; the longer bound prevents a slow but valid workflow from being downgraded to `unresolved` merely because the client abandoned the response. The durable job remains queued until all non-image units have complete structure evidence.
diff --git a/docs/adr/0122-otel-session-observability.md b/docs/adr/0122-otel-session-observability.md
index 01865559d..972275bfe 100644
--- a/docs/adr/0122-otel-session-observability.md
+++ b/docs/adr/0122-otel-session-observability.md
@@ -27,10 +27,12 @@ must not be cited as protected organization evidence.
endpoint leaves the SDK unconfigured so a later operator value can still
enable export.
2. Every contextual-orchestrator POST carries the existing
- `lineageweave_post_session_id` as `X-LineageWeave-Session-Id`. The
- orchestrator binds it to the request context and adds it to provider spans,
- so chat, Responses, structured output, VISION, and embedding work for one
- post can be investigated together.
+ `lineageweave_post_session_id` as both the top-level payload `session_id`
+ and `X-LineageWeave-Session-Id`. The orchestrator binds it to the request
+ context and adds it to provider spans, so chat, Responses, structured
+ output, VISION, and embedding work for one post can be investigated
+ together. The post identifier remains authorized provenance metadata and
+ is not copied into the public response.
3. LineageWeave emits bounded HTTP and Valkey operation spans. HTTP client
failures follow the OpenTelemetry HTTP semantic conventions: error
responses and invalid response bodies end the client span with an error.
diff --git a/docs/adr/0166-idempotent-migration-replay-window.md b/docs/adr/0166-idempotent-migration-replay-window.md
index d28402453..0980aada0 100644
--- a/docs/adr/0166-idempotent-migration-replay-window.md
+++ b/docs/adr/0166-idempotent-migration-replay-window.md
@@ -29,6 +29,13 @@ notation is an optional extension and cannot be required by this script.
PostgreSQL idempotency such as `IF NOT EXISTS` and `ON CONFLICT`; a migration
that cannot be made idempotent requires a migration ledger ADR before it is
added.
+- A later replayed migration that supersedes and drops an earlier index also
+ supersedes that earlier migration's create operation. The earlier file keeps
+ its sorted schema boundary but must not recreate a corpus-wide index that the
+ next file immediately drops. The current body-search example keeps the
+ `pg_trgm` extension in 0035 while 0036 solely owns the normalized search
+ indexes. This avoids a complete GIN build/drop cycle on every startup without
+ skipping the successor's correctness boundary.
- Execute each accepted file with `psql -X -v ON_ERROR_STOP=1`. A failed
migration stops startup instead of leaving a healthy-looking partial schema.
- Tests must cover the stable 0012 boundary and the idempotency of any changed
diff --git a/docs/adr/0208-externalize-local-mathematical-compute.md b/docs/adr/0208-externalize-local-mathematical-compute.md
index 4305ffb53..d3cf076d8 100644
--- a/docs/adr/0208-externalize-local-mathematical-compute.md
+++ b/docs/adr/0208-externalize-local-mathematical-compute.md
@@ -2,7 +2,7 @@
**Decision status:** Accepted
**Date:** 2026-08-25
-**Amends:** ADR 0003, ADR 0024, ADR 0064, ADR 0084, ADR 0132, ADR 0145,
+**Amends:** ADR 0003, ADR 0024, ADR 0062, ADR 0064, ADR 0084, ADR 0132, ADR 0145,
ADR 0148, ADR 0167, ADR 0168, ADR 0182, ADR 0185, ADR 0200, ADR 0201, and
ADR 0205
@@ -89,6 +89,13 @@ different responsibility.
the reciprocal-rank contribution formula. RankWeave's Rust CPU/GPU migration
remains open, so this slice is owner-bound but not yet final execution-contract
compliance.
+- The production-unused `embedding_client.cosine_similarity` and
+ `chunked_max_similarity` experiments are deleted instead of being assigned
+ a new local implementation. Persisted semantic units remain the retrieval
+ provenance boundary from ADR 0062. Active Global Ask cosine stays named
+ migration debt until an accepted retrieval owner publishes a versioned Rust
+ scoring envelope; LineageWeave will validate and persist that envelope, not
+ reproduce its vector arithmetic.
## Stacked delivery order
diff --git a/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md b/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md
index e696aab83..89458c4bd 100644
--- a/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md
+++ b/docs/adr/0226-macos-native-mlx-mathematical-compute-boundary.md
@@ -14,11 +14,15 @@ Xcode 15, and the macOS 14 SDK; its Linux distributions provide CPU or NVIDIA
CUDA backends instead. A Colima Linux VM therefore cannot truthfully issue an
MLX Metal execution receipt merely because its macOS host has an Apple GPU.
-ADR 0208 already assigns mathematical and psychometric arithmetic to the Rust
-cores of TEPP, fast-mlsirm, and RankWeave. Moving a formula into Python to gain
-access to MLX would violate that ownership boundary. ADR 0076's prohibition on
+ADR 0208 assigns psychometric and scientific numerical kernels to the Rust
+cores of TEPP and fast-mlsirm. RankWeave instead owns its current
+dependency-free Python retrieval-fusion, evaluation, and audit contract; that
+contract is neither a Rust kernel nor evidence for a future Rust vector-scoring
+owner. Moving an accepted TEPP or fast-mlsirm formula into Python to gain access
+to MLX would violate its ownership boundary. ADR 0076's prohibition on
LineageWeave-specific MLX model-provider routes remains unchanged: this ADR is
-about owner-repository numerical kernels, not LLM or VISION routing.
+about an already accepted owner-repository Rust kernel, not LLM, VISION,
+retrieval fusion, or an as-yet-unaccepted vector-scoring service.
## Decision
@@ -139,8 +143,12 @@ flowchart LR
- Compose stays portable. A machine without the native capability still runs
the product and honestly reports the affected measurement as unavailable or
uses a separately accepted owner CPU/CUDA result.
-- TEPP, fast-mlsirm, and RankWeave must each adopt this boundary in their own
- normative ADR before publishing an `mlx_metal` receipt.
+- TEPP and fast-mlsirm must each adopt this boundary in their own normative ADR
+ before publishing an `mlx_metal` receipt for an accepted Rust kernel.
+- RankWeave's current Python retrieval contract is unchanged by this ADR. Any
+ future Rust vector-scoring owner requires its own accepted ownership and wire
+ contract before this accelerator boundary can apply; this ADR does not assign
+ that responsibility or require RankWeave to adopt MLX.
## Alternatives considered
diff --git a/docs/adr/0228-evidence-bound-product-semantic-catalog.md b/docs/adr/0228-evidence-bound-product-semantic-catalog.md
new file mode 100644
index 000000000..6d2aa37ab
--- /dev/null
+++ b/docs/adr/0228-evidence-bound-product-semantic-catalog.md
@@ -0,0 +1,89 @@
+# ADR 0228: Evidence-bound product semantic catalog
+
+- Status: Accepted
+- Date: 2026-08-26
+- Governs: product extraction, identity resolution, typed product relations, and historical backfill
+
+## Context
+
+Product references currently remain inside source text or unrelated operational
+facts. Treating a word or tag as a product would conflate a text match with an
+identified business entity, while forcing a best match would hide homonyms.
+Imported weak or blank category/customer values remain raw source provenance,
+not final semantic categories or resolved identities.
+ADR 0184 also requires typed ontology navigation to remain distinct from Event
+Lineage. ADRs 0036, 0052, and 0206 require authorized source evidence and exact
+input provenance for semantic and operational assertions.
+
+## Decision
+
+`product_catalog` is the shared product identity across `product_group`,
+`product_model`, `variant`, and `trade_item` levels. A parent foreign key
+retains that hierarchy. Scoped GTIN and MPN identifiers live in
+`product_catalog_identifier`; an identifier without issuer scope is not an
+identity. `product_catalog_alias` is its normalized lookup vocabulary. Multiple catalog
+identities may intentionally share an alias. A contextual-orchestrator
+structured extraction supplies only product mentions and verbatim source
+spans. LineageWeave validates the span against the authorized source, records
+its post and SHA-256 digest, and resolves the normalized alias with four
+outcomes:
+
+- exactly one catalog identity: `unique`, with its foreign key;
+- no catalog identity: `missing`, without a foreign key;
+- more than one identity: `tie`, without a foreign key.
+- unavailable catalog lookup: `unavailable`, without a foreign key.
+
+Neither `missing` nor `tie` creates a catalog row. Keywords, tags, fuzzy
+thresholds, provider calls, and locally guessed identities are prohibited.
+Relations to operational facts and project mentions use foreign keys to the
+existing normalized stores. These typed relations are an ontology navigation
+projection, not Event Lineage.
+
+```mermaid
+flowchart LR
+ S[source_post] -->|authorized span and digest| M[post_product_mention]
+ A[product_catalog_alias] -->|unique only| M
+ M --> P[product_catalog]
+ M --> F[operations_case_fact]
+ M --> J[post_project_mention]
+```
+
+Historical processing reuses the durable post-content queue boundary, with a
+bounded operator request and digest idempotency. HTTP requests never perform
+the extraction inline. Each post's product projection extracts only from that
+focal post's normalized source body; linked evidence remains available to
+operations inference but cannot make a sibling's product appear on the focal
+post. Publication applies the existing authorization filter
+and source eligibility predicate to both the requested post and every evidence
+post before returning the mention, relation, or evidence link. A visible post
+cannot reveal a product span cited only by evidence the reader cannot access.
+
+## Consequences
+
+- A product connection is auditable back to an exact authorized source span.
+- Catalog ambiguity remains visible and cannot silently become identity.
+- Operational and project relations reuse their existing evidence-bearing
+ normalized objects instead of duplicating unstructured values.
+- Catalog stewardship is required before missing or tied mentions can become
+ linked products.
+- High-volume deployments can partition mention and relation tables by a
+ future tenant/time key without changing their logical contract; indexes put
+ lookup keys before post identifiers to avoid one hot post partition.
+
+## Alternatives rejected
+
+- Keyword or tag classification: lexical occurrence does not establish product
+ identity or a typed business relation.
+- Model-generated catalog creation: generated identities cannot satisfy the
+ unique/miss/tie evidence boundary.
+- One polymorphic relation target column: it weakens referential integrity and
+ violates the normalized ownership of projects and operational facts.
+
+## References
+
+Bhattacharya, I., & Getoor, L. (2007). Collective entity resolution in
+relational data. *ACM Transactions on Knowledge Discovery from Data, 1*(1),
+Article 5. https://doi.org/10.1145/1217299.1217304
+
+Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*.
+World Wide Web Consortium. https://www.w3.org/TR/prov-dm/
diff --git a/docs/adr/0230-source-preserving-voice-semantic-taxonomy.md b/docs/adr/0230-source-preserving-voice-semantic-taxonomy.md
new file mode 100644
index 000000000..9ad1bca8e
--- /dev/null
+++ b/docs/adr/0230-source-preserving-voice-semantic-taxonomy.md
@@ -0,0 +1,63 @@
+# ADR 0230: Source-preserving voice semantic taxonomy
+
+- Status: Accepted
+- Date: 2026-08-26
+
+## Context
+
+The imported `source_post.voc_type_code` is provenance, not permission to
+overwrite the source or collapse organization relationships into one post
+label. Two vocabularies already exist: post types `voc`, `vocc`, `voco`, `vom`,
+and `vop`; and post-scoped organization relationships `rel_voc`, `rel_vocc`,
+`rel_voco`, `rel_vom`, `rel_vop`, and `rel_vos`. Internal `rel_vos` means Voice
+of Supplier. It is not ISO 16355's Voice of Stakeholder and is not in the post
+type scheme.
+
+## Decision
+
+Source assertions and contextual-orchestrator-derived assertions are append-only
+and separate. Derived assertions require an exact source span, source revision
+digest, evidence digest, model receipt, and optional validity interval. A post
+or organization may have multiple simultaneous memberships. Conflicting
+source and derived concept sets remain disagreement evidence; matching
+multi-membership sets are agreement, not a pairwise mismatch. A summary admits
+an assertion only while its optional validity interval contains the query
+instant. Imported source labels have no business-event validity interval: they
+are available as provenance as soon as recorded, even when the post describes
+a future event. Optional validity intervals describe derived or explicitly
+time-scoped relationship claims, not ingestion availability. No threshold,
+weight, keyword, alias rule, or forced winner is permitted. A replacement or
+retraction names the superseded assertion and closes validity with provenance.
+The database reconciles the source assertion in the same transaction that
+inserts or changes `source_post.voc_type_code` or its revision-bearing body.
+It retains the prior assertion as a closed, superseded version; migration
+replay is a recovery/backfill path, not the normal ingestion lifecycle.
+
+Counts use the same authorized eligible-post denominator at the same cutoff and
+filters. They report source, derived, multi-membership, disagreement, and
+unavailable counts. Per-category membership percentages divide by all eligible
+posts and disclose that overlapping category counts may exceed the denominator.
+Organization-relationship counts use a separately named evidence-bearing
+post-by-organization denominator. Filters may narrow period, corporate entity,
+PU, team, person, product, or project without changing these denominators.
+
+SHACL excludes `rel_vos` from the post scheme, admits it only in the organization
+relationship scheme, and requires derived evidence/digest/receipt/time fields.
+Raw `source_post.voc_type_code` is never updated by this projection.
+
+## Consequences
+
+- Operators can compare original and derived semantics without losing either.
+- Category totals are intentionally non-additive under multi-membership.
+- A missing orchestrator result remains unavailable, never a negative class.
+- Product-scoped supplier/customer transitions can coexist across intervals.
+
+## References
+
+International Organization for Standardization. (2017). *ISO 16355-4:2017:
+Applications of statistical and related methods to new technology and product
+development process—Part 4: Analysis of non-quantitative and quantitative Voice
+of Customer and Voice of Stakeholder*. https://www.iso.org/standard/62607.html
+
+Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*.
+World Wide Web Consortium. https://www.w3.org/TR/prov-dm/
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 2245a7cd8..5a3b745d0 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -27,6 +27,8 @@ decision from them.
| Ask answer citation and evidence-timeline interaction | [0225](0225-ask-answer-evidence-timeline.md) |
| macOS-native Rust/MLX mathematical compute boundary | [0226](0226-macos-native-mlx-mathematical-compute-boundary.md), [0208](0208-externalize-local-mathematical-compute.md) |
| Observed PostgreSQL WAL/checkpoint tuning plan | [0227](0227-observed-postgresql-runtime-tuning.md) |
+| Product semantic catalog and typed evidence relations | [0228](0228-evidence-bound-product-semantic-catalog.md) |
+| Source-preserving voice semantic taxonomy | [0230](0230-source-preserving-voice-semantic-taxonomy.md) |
[0011](0011-prov-o-standard-relations.md) and [0065](0065-prov-o-provenance-boundary.md) cite the dated W3C PROV-O and PROV-DM Recommendations (https://www.w3.org/TR/2013/REC-prov-o-20130430/ and https://www.w3.org/TR/2013/REC-prov-dm-20130430/).
diff --git a/docs/doctoring/python-mathematical-compute-boundary-audit.md b/docs/doctoring/python-mathematical-compute-boundary-audit.md
index 108e136b2..7bb086cc6 100644
--- a/docs/doctoring/python-mathematical-compute-boundary-audit.md
+++ b/docs/doctoring/python-mathematical-compute-boundary-audit.md
@@ -8,8 +8,8 @@ does not relabel still-local Python paths as Rust/GPU compliant.
## Product-boundary sources read
-- LineageWeave `ARCHITECTURE.md` and accepted ADRs 0003, 0132, 0145,
- 0200, 0201, and 0205. This exact head has no standalone canonical PRD.
+- LineageWeave `docs/product-requirements.md`, `ARCHITECTURE.md`, and accepted
+ ADRs 0003, 0062, 0132, 0145, 0200, 0201, and 0205.
- TEPP `docs/product/prd-v0.4-approved.md`, whose approved TRSL-TM scope
owns temporal, relational, multilingual, topic, event, and trajectory
measurement.
@@ -25,10 +25,12 @@ does not relabel still-local Python paths as Rust/GPU compliant.
| `lineageweave/channel_weight_estimation.py` | dichotomization, synthetic simulation, MLS2PLM input construction, expected item information and normalization | fast-mlsirm, conditional on TEPP anchor | versioned anchored-weight artifact; strict digest/convergence validation | estimation scripts, seed/server/rebuild paths; `tests/test_channel_weight_estimation.py`, estimator-script tests |
| `lineageweave/period_report.py` | response matrix and owner-call orchestration remain; local category expectation and duplicate likelihood arithmetic removed | fast-mlsirm | `polytomous_expected_response`; diagnostics-owned held-out log likelihood; full period artifact remains debt | report ingestion and demo seed; period-report and report API tests |
| `lineageweave/leftover_pairs.py` | **migrated:** identifier projection and closest/farthest selection only | fast-mlsirm | protected-main Rust `residual_interaction_map` with residual, coverage, SVD/Gabriel coordinates, distances, reconstruction and shares | `period_report.py`, report ingestion/seed; owner contract and consumer projection tests |
-| `lineageweave/embedding_client.py` and `backend/app/post_chat_ingestion.py` | cosine similarity, vector norms, maximum semantic score | RankWeave retrieval-score contract | ranked evidence envelope over ABAC-visible semantic units | reconstruction text channel and Global Ask retrieval; embedding/post-chat tests |
+| `backend/app/post_chat_ingestion.py` | active Global Ask cosine, vector norm, maximum semantic score; the unused `embedding_client.py` cosine/max-pooling experiment is deleted | RankWeave or another accepted Rust retrieval-score owner | versioned ranked-evidence envelope over ABAC-visible semantic units; fail closed until accepted | Global Ask retrieval and post-chat tests |
| `lineageweave/knowledge_graph.py` | random walk with restart, convergence delta, adaptive relevance cutoff | RankWeave graph-ranking contract | ranked-node artifact with contribution and convergence evidence | related-person/entity API paths; knowledge-graph tests |
+| `lineageweave/channels.py` | local time-decay score and `SequenceMatcher` text similarity fallback | RankWeave similarity contract; TEPP supplies temporal evidence | owner-computed, provenance-bearing channel evidence | `reconstruct.py`; channel and reconstruction tests |
| `lineageweave/reconstruct.py` | channel-weight renormalization, candidate-score fusion and minimum-score decision | RankWeave fusion; TEPP supplies independent lineage criterion | accepted edge-ranking artifact; LineageWeave persists selected edge and channel provenance | lineage rebuild/start/seed/server; reconstruct, persistence, API tests |
| `lineageweave/rankweave_client.py` | channel construction and token overlap remain; **owner-bound:** classic/weighted RRF and contribution arithmetic now come from RankWeave #47, whose Python core still awaits the required Rust CPU/GPU migration | RankWeave | Rust-backed strict ranking artifact exposing owner-computed contributions and owned channel construction | `/api/rankings`, frontend Rankings; `tests/test_rankweave_client.py` and frontend tests |
+| `lineageweave/corporate_hierarchy_resolution.py` | `SequenceMatcher` organization-name similarity, score threshold, and top-score selection | external entity-resolution owner contract required | unique/miss/tie catalog-resolution artifact with evidence and policy version | organization resolution ingestion; corporate-hierarchy and API tests |
`lineageweave/post_evaluation.py` imports fast-mlsirm only for its published
judge contract and `to_irt_row` projection. It performs no fitted numerical
@@ -38,6 +40,9 @@ import must be reviewed before LineageWeave's final wire-only state.
Validation-only uses of `math.isfinite` and database aggregation are not model
ownership and remain. Date ordering, counts, pagination, authorization, schema
validation, and presentation formatting also remain LineageWeave concerns.
+Exact JSON UTF-8 body length, server-advertised token/input ceilings, vector
+dimension equality, and finite-number checks in embedding backfill validate an
+owner envelope; they neither estimate token counts nor calculate similarity.
## Required owner contracts
@@ -62,3 +67,36 @@ labels do not replace foreign keys. Dashboard and post detail endpoints read
only accepted persisted rows and preserve source-post ABAC. Storybook covers
accepted, pending, failed, stale-digest, non-converged, hidden-evidence, and
multiple-membership cases before UI activation.
+
+## 2026-08-26 stacked-PR audit
+
+The exact reviewed heads were PR #692 `583059edcffe994b18a6fbf3cb3b00bf4647c2a3`,
+PR #693 `999063d22e60469227eeea308fee787683952cab`, and PR #694
+`296cbae6c9ac2839b0f5ff150ae02ebf4f726627`. The review used CodeGraph before
+diff inspection.
+
+- PR #692 adds evidence-span normalization, unique/miss/tie catalog binding,
+ persistence, and projection. It adds no statistical score, vector algebra,
+ fitted weight, or local model.
+- PR #693's Python code validates vector shape and finiteness, serializes the
+ exact UTF-8 request body, and chooses a prefix under an upstream-advertised
+ byte ceiling. Those are transport and schema-validation operations allowed
+ by ADR 0208, not token estimation or vector scoring. Tokenization, token
+ ranges, provider-limit packing, checked token totals, and shard construction
+ are owned by contextual-orchestrator's Rust/PyO3 extension pinned by the
+ Docker build. The owner follow-up PR #865 is stacked on the current owning
+ #857 branch and fails closed at an undecodable
+ token ceiling and preserves complete UTF-8 scalars when a nominal token
+ boundary divides their byte representation.
+- PR #694 delegates overlap counts and the shared eligible denominator to one
+ authorization-filtered SQL aggregate. Converting those returned counts to a
+ displayed percentage is presentation formatting, explicitly outside the
+ model-ownership inventory. It supplies no threshold, category weight,
+ probability model, or forced winner.
+
+No new Python mathematical or psychometric implementation was found in this
+stack. The highest-leverage newly exercised owner path is therefore the Rust
+token packer rather than a duplicate LineageWeave implementation. Existing
+time-decay and string similarity, cosine, graph-ranking, fusion, period-report,
+and anchored channel-weight debt remains frozen under the owner and acceptance
+criteria above; this audit does not reclassify it as complete.
diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md
index a0f202468..9f756450d 100644
--- a/docs/lineage-bi-research-notes.md
+++ b/docs/lineage-bi-research-notes.md
@@ -98,10 +98,9 @@ Embedding a whole flattened document as one vector dilutes a short
relevant unit with everything else in the same document -- the vector
averages over content that has nothing to do with the match being sought.
`lineageweave/chunking.py` splits a document into meaning-identifiable
-units first; `embedding_client.chunked_max_similarity` embeds every unit
-and takes the single highest-scoring pair, which is the standard
-passage-retrieval strategy for "a relevant unit is buried in a longer
-document." Four unit types, each grounded in a real boundary concept:
+units first. ADR 0208 removed the unused local Python cosine/max-pooling
+experiment; a versioned Rust retrieval-owner envelope must perform any future
+unit scoring. Four unit types remain, each grounded in a real boundary concept:
- **paragraph** -- subtopic-passage boundaries (Hearst, 1997, TextTiling).
- **sentence** -- the finer unit inside a paragraph.
@@ -114,10 +113,8 @@ document." Four unit types, each grounded in a real boundary concept:
**Honest scope note for this project's real dataset**: the real dataset
validated against in milestone 2 (43,814 short business records) has only
one real free-text field, and it is short (~28 characters average) with no
-paragraph, DOM, or conversation structure to chunk -- chunking a title
-does nothing useful and `chunked_max_similarity` degrades gracefully to
-plain whole-text embedding for exactly this case (a document that chunks
-to zero or one piece is embedded once, same as before chunking existed).
+paragraph, DOM, or conversation structure to chunk, so unit persistence does
+not imply or fabricate a local similarity score.
This module exists for when a richer content source is embedded --
concretely, the raw MHTML source artifacts this dataset's records were
derived from (tracked only as opaque content-addressed references in this
diff --git a/docs/ontology/lineageweave-kg-shapes.ttl b/docs/ontology/lineageweave-kg-shapes.ttl
index 187ebb0f1..057b7dec9 100644
--- a/docs/ontology/lineageweave-kg-shapes.ttl
+++ b/docs/ontology/lineageweave-kg-shapes.ttl
@@ -186,6 +186,89 @@
sh:class :Post ;
] .
+:ProductMentionShape a sh:NodeShape ;
+ rdfs:label "Evidence-bound product mention shape" ;
+ sh:targetClass :ProductMention ;
+ sh:property [
+ sh:path :extractedProductName ;
+ sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:minLength 1 ;
+ ] ;
+ sh:property [
+ sh:path :productResolutionStatus ;
+ sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in ("unique" "missing" "tie" "unavailable") ;
+ ] ;
+ sh:property [
+ sh:path :evidenceInputDigest ;
+ sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ;
+ sh:minCount 1 ; sh:maxCount 1 ;
+ sh:class :Post ;
+ ] .
+
+:PostVoiceClassificationAssertionShape a sh:NodeShape ;
+ sh:targetClass :PostVoiceClassificationAssertion ;
+ sh:property [
+ sh:path :voiceConceptCode ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in ("voc" "vocc" "voco" "vom" "vop") ;
+ ] ;
+ sh:property [
+ sh:path :voiceAssertionStatus ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in ("source" "derived") ;
+ ] ;
+ sh:property [
+ sh:path :voiceEvidenceDigest ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [
+ sh:path :sourceRevisionDigest ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :Post ;
+ ] ;
+ sh:property [ sh:path :validFrom ; sh:maxCount 1 ; sh:datatype xsd:dateTime ; sh:lessThanOrEquals :validTo ] ;
+ sh:property [ sh:path :validTo ; sh:maxCount 1 ; sh:datatype xsd:dateTime ] ;
+ sh:or (
+ [ sh:property [ sh:path :voiceAssertionStatus ; sh:hasValue "source" ] ]
+ [
+ sh:property [ sh:path :voiceAssertionStatus ; sh:hasValue "derived" ] ;
+ sh:property [ sh:path :orchestratorModelReceipt ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:string ; sh:minLength 1 ] ;
+ sh:property [ sh:path :evidenceSpanStart ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ; sh:minInclusive 0 ; sh:lessThan :evidenceSpanEnd ] ;
+ sh:property [ sh:path :evidenceSpanEnd ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ]
+ ]
+ ) .
+
+:OrganizationVoiceRelationshipAssertionShape a sh:NodeShape ;
+ sh:targetClass :OrganizationVoiceRelationshipAssertion ;
+ sh:property [
+ sh:path :voiceConceptCode ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:in ("rel_voc" "rel_vocc" "rel_voco" "rel_vom" "rel_vop" "rel_vos") ;
+ ] ;
+ sh:property [
+ sh:path :orchestratorModelReceipt ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:minLength 1 ;
+ ] ;
+ sh:property [
+ sh:path :voiceEvidenceDigest ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [
+ sh:path :sourceRevisionDigest ; sh:minCount 1 ; sh:maxCount 1 ;
+ sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ;
+ ] ;
+ sh:property [ sh:path :evidenceSpanStart ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ; sh:minInclusive 0 ; sh:lessThan :evidenceSpanEnd ] ;
+ sh:property [ sh:path :evidenceSpanEnd ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ] ;
+ sh:property [ sh:path :validFrom ; sh:maxCount 1 ; sh:datatype xsd:dateTime ; sh:lessThanOrEquals :validTo ] ;
+ sh:property [ sh:path :validTo ; sh:maxCount 1 ; sh:datatype xsd:dateTime ] ;
+ sh:property [
+ sh:path prov:wasDerivedFrom ; sh:minCount 1 ; sh:maxCount 1 ; sh:class :Post ;
+ ] .
+
:OurSidePersonShape a sh:NodeShape ;
rdfs:label "Our-side person shape" ;
sh:comment "Closed-world complement of :OurSidePerson owl:disjointWith :CounterpartyPerson: an instance of one can never be typed as the other." ;
diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl
index 90ef1c4cb..322a868b0 100644
--- a/docs/ontology/lineageweave-kg.ttl
+++ b/docs/ontology/lineageweave-kg.ttl
@@ -467,6 +467,59 @@
rdfs:subClassOf rdf:Statement, prov:Entity ;
rdfs:label "Operations case fact"@en .
+:Product a owl:Class ;
+ rdfs:label "Product"@en ;
+ rdfs:comment "A governed product catalog identity at group, model, variant, or trade-item level."@en .
+
+:ProductMention a owl:Class ;
+ rdfs:label "Product mention"@en ;
+ rdfs:comment "A source-span-bound product mention with a fail-closed catalog resolution outcome."@en .
+
+:mentionsProduct a owl:ObjectProperty ;
+ rdfs:domain :ProductMention ; rdfs:range :Product .
+
+:extractedProductName a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:productResolutionStatus a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:evidenceInputDigest a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:PostVoiceClassificationAssertion a owl:Class ;
+ rdfs:label "Post voice classification assertion"@en .
+
+:OrganizationVoiceRelationshipAssertion a owl:Class ;
+ rdfs:label "Organization voice relationship assertion"@en .
+
+:voiceConceptCode a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:voiceAssertionStatus a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:voiceEvidenceDigest a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:sourceRevisionDigest a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:evidenceSpanStart a owl:DatatypeProperty ;
+ rdfs:range xsd:integer .
+
+:evidenceSpanEnd a owl:DatatypeProperty ;
+ rdfs:range xsd:integer .
+
+:validFrom a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime .
+
+:validTo a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime .
+
+:orchestratorModelReceipt a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
:Order a owl:Class ; rdfs:label "Order"@en .
:SalesContext a owl:Class ; rdfs:label "Sales context"@en .
:BusinessManagementContext a owl:Class ; rdfs:label "Business-management context"@en .
diff --git a/docs/product-requirements.md b/docs/product-requirements.md
index fcb9f741e..1d9795cff 100644
--- a/docs/product-requirements.md
+++ b/docs/product-requirements.md
@@ -185,12 +185,32 @@ A release claim requires one exact protected-main head that proves:
7. synchronized PRD, ADR, architecture, API, changelog, and product-gap
baseline.
+### 6.1 Product identity and evidence relationships
+
+The product must extract product mentions through contextual-orchestrator from
+authorized semantic source units, validate verbatim evidence, and resolve only
+against the normalized product catalog. Product group, model, variant, and
+trade-item identities preserve their hierarchy and scoped GTIN/MPN keys.
+Unique, tied, missing, and unavailable outcomes remain distinct. Product links
+to posts, projects, orders, sales pools, specification changes, claims, and
+external information reuse normalized evidence-bearing records and never
+derive identity from keywords, tags, weak source sentinels, or arbitrary
+similarity thresholds. Historical processing is bounded, asynchronous,
+digest-idempotent, and authorization-filtered when read.
+
+The source post voice scheme (`voc`, `vocc`, `voco`, `vom`, `vop`) and
+post-scoped organization relationship scheme (the same five relationships plus
+supplier `rel_vos`) remain distinct. Source and derived assertions coexist;
+multi-membership and disagreements are reported without forced selection.
+Authorized counts use the same period and organization/PU/team/person/product/
+project filters and disclose overlapping category totals.
+
## 7. Traceability
- Product/data boundary: ADR 0001, ADR 0089.
- Asynchronous delivery and database-pool isolation: ADR 0204.
- Knowledge Graph, ontology, and provenance: ADR 0004, ADR 0011, ADR 0065,
- ADR 0184, ADR 0207.
+ ADR 0184, ADR 0207, ADR 0228.
- Semantic units and retrieval: ADR 0047, ADR 0062, ADR 0102.
- LLM/model boundary: ADR 0070, ADR 0072, ADR 0076, ADR 0079.
- Measurement: ADR 0003, ADR 0145, ADR 0200, ADR 0205.
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index c61a43617..b2e3f6c8a 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -25,10 +25,10 @@ containers while preserving named volumes.
| Rebid/handover: discussion, counterparties, our owner, decisions, Event/post counts | ADR 0206; normalized case facts plus separate rebid-response and handover milestone pairs | Stacked candidate reports open/resolved/evidence-missing rebid and handover lifecycles without a delay threshold or invented elapsed endpoint; authenticated synthetic runtime passed, while authorized-corpus re-analysis remains pending |
| External information count/rate and sales/project relation | ADR 0206; semantic `external_information` classification inside Dashboard GNB | Candidate GNB destination filters the Dashboard to external evidence; no separate Board by product decision; authenticated synthetic runtime passed with the honest zero-result state |
| Project-specific journey | Explicit source/semantic project membership plus provenance-bearing TEPP TDT/CHRONOS predecessor, branch, and transition results | Candidate API preserves every explicit project membership, but its local event-time sort is only an observed-event list. It is no longer labeled as a Project Journey. Full journey delivery remains open until the accepted TEPP producer artifact is persisted and rendered; no fixed sales/order start or nearest-date edge is accepted. |
-| Apple-Silicon mathematical acceleration | ADR 0226 macOS-native Rust owner service with authenticated MLX Metal execution receipts | Normative boundary accepted; TEPP, fast-mlsirm, and RankWeave owner implementations and actual Metal parity receipts remain required before activation |
+| Apple-Silicon mathematical acceleration | ADR 0226 macOS-native Rust owner service with authenticated MLX Metal execution receipts | Normative boundary applies to accepted TEPP and fast-mlsirm Rust kernels; their owner implementations and actual Metal parity receipts remain required before activation. RankWeave's current dependency-free Python retrieval-fusion/evaluation contract is not Rust acceleration evidence and is not required to adopt MLX; a future Rust vector-scoring owner remains a separately accepted contract gap. |
| Ask answer citation-to-event navigation | ADR 0225; authorized cited source, observed source clock, focused evidence layer, and full-post navigation | Stacked candidate renders numbered answer citations and chronologically ordered evidence cards with bidirectional focus. Event and record clocks stay distinct; this list does not claim a Project Journey. Storybook and component interaction evidence are included; authenticated runtime screenshots remain required at the exact candidate head. |
| Repeat issue to design improvement | `repeat_issue`, `issue_pattern`, and `improvement_action` cited facts | Candidate semantic contract; design-system connector acceptance pending |
-| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback. Post-scoped evidence collection follows exact persisted `project_key` membership, and newly analyzed project evidence durably requeues completed sibling analyses that still have missing facts. Case-analysis reuse is bound to the exact ordered authorized evidence window and context, so the unchanged focal record re-analyzes when that window changes. Focused backend and replay-safe schema tests pass; authenticated exact-head runtime acceptance remains pending. |
+| Natural-language Ask with evidence, report, alert, MCP | Persisted semantic-unit embeddings plus versioned delivery/resource contract | Candidate implementation uses whole-question embedding retrieval with no lexical fallback. Post-scoped evidence collection follows exact persisted `project_key` membership, and newly analyzed project evidence durably requeues completed sibling analyses that still have missing facts. Case-analysis reuse is bound to the exact ordered authorized evidence window and context, so the unchanged focal record re-analyzes when that window changes. Canonical acceptance completed one provider-backed 2,048-input batch and atomically persisted 2,048 semantic-unit vectors (6,291,456 dimension values) with zero duplicate units and post-scoped session mismatch count 0. The provider step was durable before persistence, while normalized dimension insertion required about four minutes, peaked near one CPU of PostgreSQL, and showed no database wait event or OOM. The remaining product gap is a measured storage-throughput boundary: preserve atomic replacement and normalized auditability while reducing dimension-write CPU and operator-memory cost through a separately reviewed database contract; do not weaken WAL durability or expose partially replaced vectors. |
| Similar VOC, customer cohort, prior action | Persisted repeat-issue candidate semantics plus orchestrator pair adjudication and extractive evidence | Candidate live post endpoint and post-detail UI implemented; authenticated runtime acceptance pending |
| TEPP independent Event Lineage anchor | Accepted, persisted TEPP criterion bound to exact snapshot/cutoff before fast-mlsirm activation | Consumer PR #606 is on protected main; TEPP producer PR #237 remains open, so no end-to-end accepted artifact is release evidence yet |
| Temporal Lineage topics and multilevel important posts | ADR 0210; TEPP posterior topic/plausible-value contract followed by fast-mlsirm observed-information case-deletion influence | This stacked candidate adds normalized persistence, exact run/snapshot/cutoff binding, pre-aggregation scope authorization, API diagnostics, and populated/unavailable Storybook surfaces. TEPP PR #247 remains open at `063f10f3`; stacked #251–#254 provide fail-closed input validation, full joint precision, deterministic joint plausible-value draws, and the canonical research register, while complete provenance assembly remains gated. fast-mlsirm PR #1418 validates the Rust consumer envelope but intentionally returns `EstimatorUnavailable` until the scientific estimator lands. Runtime therefore remains honestly unavailable with no local Python or fallback score. |
@@ -447,7 +447,7 @@ this file per §3.5 of the prior snapshot).
| Gap | Current evidence | Acceptance requirement |
| --- | --- | --- |
| Protected release | 11 open PRs at the 07:26 KST snapshot; the exact-head inventory in section 1 records their current evidence boundaries | Terminal exact-head checks, no unresolved threads, independent exact-head approvals, protected squash-merge SHA |
-| Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. The current Dashboard stack adds a candidate `post_admin`-gated, 1--200-row durable semantic-backfill enqueue path that reuses PostgreSQL recovery and never runs providers in HTTP; authorized-corpus acceptance remains unavailable | Land the candidate, then perform authenticated authorized-corpus acceptance with aggregate queued/published/recovery and derived-evidence counts while retaining fail-closed no-match behavior |
+| Evidence-grounded operations workspace | Protected-main #614 delivers governed semantic Ask, live Similar VOC, disjoint pending/failed analysis metrics, full Storybook state inventory, and current desktop/mobile screenshot evidence. The current Dashboard stack adds a candidate `post_admin`-gated, 1--200-row durable semantic-backfill enqueue path that reuses PostgreSQL recovery, includes successful records completed before operations extraction, and never runs providers in HTTP; authorized-corpus acceptance remains unavailable | Land the candidate, then perform authenticated authorized-corpus acceptance with aggregate queued/published/recovery and derived-evidence counts while retaining fail-closed no-match behavior |
| Shared frontend gate | The ADR 0109 login repair is on protected `main`; eight older branches carried the defect and received the same verified repair this loop (#521–#560) | Keep every future branch cut from post-repair bases; re-verify with frontend lint/test/build before push |
| Identifying baseline regression | `main` gap file listed real post identifiers; separately, closed #506 and pre-existing public history contain a private runtime source-table identifier, while current `main` and #507 trees are clean | Land this non-identifying rewrite, then coordinate ADR 0001 history remediation with security/privacy owners; do not reproduce the value, force-push, or delete evidence ad hoc |
| Authorized-corpus runtime | Repository tests use synthetic fixtures; private records remain outside git | Authenticated runtime validation returning only aggregate, non-identifying evidence |
@@ -455,13 +455,15 @@ this file per §3.5 of the prior snapshot).
| Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing |
| Semantic source rendering | ADR 0223 and migration 0221 give new paragraph, list, table, MathML formula, and caller-parsed conversation-turn units explicit persisted kinds without rewriting historical rows; image regions remain ordered normalized children under ADR 0091. This branch is candidate evidence, not protected-main delivery | Land the exact-head candidate, then prove an authorized semantic-only query retrieves each persisted unit kind and gather authenticated browser evidence that nesting, continuation alignment, formula units, and image regions retain source order |
| Event and project semantics | Multi-project mentions, project-bound actions, 5W1H, requester/processor, and semantic relations exist in ADR 0036/0052/0100/0111/0129 and active stacks | Aggregate authenticated evidence must show distinct projects and events, explicit requester/processor and real R&R, normalized relative time, and product/entity relations without promoting attendance or co-occurrence |
+| Product semantic identity | ADR 0228 and migration 0228 define normalized product group/model/variant/trade-item identities, scoped GTIN/MPN keys, exact-span provenance, fail-closed unique/tie/missing/unavailable resolution, and foreign-key relations to existing project and operations facts. The worker candidate reuses the durable post-content queue and skips an unchanged authorized input digest. No authorized-corpus product counts or rendered acceptance evidence are recorded | Land the stack, add authorization-filtered Post/Dashboard relationship reads and SHACL projection, then verify aggregate-only backfill outcomes plus desktop/mobile Storybook screenshots without exposing identifying runtime rows |
+| Voice semantic taxonomy | ADR/migration 0230 preserve the five-value source post scheme separately from the six-value post-scoped organization relationship scheme, retain source/derived disagreement and multi-membership, and provide authorized overlap-aware aggregate filters. Candidate Storybook evidence is synthetic; no private-corpus derived assertion count is recorded | Land the stack, run bounded orchestrator backfill, and verify aggregate-only source/derived/disagreement/unavailable counts at one declared cutoff without exposing record identities |
| Knowledge Graph readability | The black evidence-node root cause is an undefined-token fallback; the design-token repair and long-label/evidence-table coverage remain only on closed, unmerged #490, not protected `main` | Recreate the token repair on a current base and deliver it through protected `main`, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface |
| Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding |
| Calendar / Naruon | #355 delivered the projection contract; v2.17.0 wires operator consumption without forwarding the end-user token. Naruon producer, provider/consumer fixtures, and protected merge remain open (#336) | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired |
| SKOS organization aliases | Catalog binding and chip caption live on #480 / #482 | One catalog row per corroborated org; companion caption is hint-only until bound |
| Event Lineage evidence | Channel evidence and Allen relations live on #387 / #484 | Persist channel scores, explain them in the popup, never invent a fused score |
| Scientific measurement | Durable accepted TEPP receipts and LineageWeave #614's exact accepted snapshot/cutoff/run/pair-count consumer are protected; TEPP #237 remains open, so no registered producer artifact exists yet. #387 removes inferred/default persistence weights, but several older reconstruction tests still pass hand-authored numeric dictionaries that are not estimator evidence | Land TEPP #237 through its protected gate, then replace remaining reconstruction-test constants with provenance-bearing fast-mlsirm estimates over synthetic fixtures. Retain true-parameter RMSE recovery as the acceptance bar |
-| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stack also removes local RRF contribution arithmetic and corrects the invalid all-ones call to RankWeave's convex API through RankWeave #47 (`f92c3a2c`), but RankWeave #47 is still Python and therefore is not the final Rust CPU/GPU execution contract. The backend pins fast-mlsirm protected-main `09f762ded35786dd1078222a4577ff09d649816f`; TEPP-specific fast-mlsirm PR #1423 closed unmerged and is not a valid owner contract. The doctoring inventory still names period calibration, channel weighting, cosine, graph ranking, and fusion debt | Define and land a domain-neutral pair-level criterion-observation contract in fast-mlsirm, with independent event-time provenance and CPU/GPU parity receipts; only then land the Rust estimator and delete the frozen Python channel-weight path. Continue the separate RankWeave and period-report owner migrations before requiring an empty transition inventory |
+| Python mathematical-compute boundary | ADR 0208's first deletion slice consumes fast-mlsirm protected-main Rust `residual_interaction_map` and `polytomous_expected_response`; local Gabriel SVD, distance, reconstruction, shares, expected-category and duplicate likelihood formulas are deleted. This stack also removes local RRF contribution arithmetic and corrects the invalid all-ones call to RankWeave's convex API through RankWeave #47 (`f92c3a2c`), but RankWeave #47 remains Python and is not the final Rust CPU/GPU execution contract. The backend pins fast-mlsirm protected-main `09f762ded35786dd1078222a4577ff09d649816f`; TEPP-specific fast-mlsirm PR #1423 closed unmerged and is not a valid owner contract. The stacked deletion slice removes production-unused Python cosine/max-pooling helpers and freezes the one active direct-vector path with an AST inventory. Global Ask cosine remains Python migration debt because no accepted versioned Rust retrieval-scoring contract exists; embedding backfill performs only exact request-envelope sizing, advertised-ceiling validation, vector-shape validation, and persistence, not token estimation, model selection, or vector algebra. contextual-orchestrator owns automatic model discovery, selection, tokenization, packing, and provider execution. The doctoring inventory still names period calibration, channel weighting, time-decay and string similarity, cosine, graph ranking, and fusion debt. | Land the contextual-orchestrator owner changes through their protected gates and advance LineageWeave's immutable pin only to a protected owner commit. Land a versioned Rust RankWeave envelope for authorization-visible semantic-unit scoring with model/version provenance, input digest, deterministic CPU/GPU parity, finite dimensions, ranked evidence, and explicit failure. Then switch Global Ask to strict envelope validation, prove authorized semantic retrieval and zero-provider fail-closed behavior, and delete `KNOWN_LOCAL_DIRECT_VECTOR_ARITHMETIC`. Separately land the domain-neutral anchored-weight contract before deleting frozen channel-weight code. |
| Asynchronous authorization | Protected `main` rebuilds Global Ask worker scope after the bearer token leaves the request; #468 now persists exact Keyverse organization/process-unit scope in 3NF child tables and intersects it with current affiliations | Land #468 through the protected gate; prove a second affiliation and a revoked process unit cannot widen delayed-job evidence |
| Planned-facility intent | Planned-facility relationship intent remains only on closed, unmerged #490; earlier stack-only merges were not protected delivery | Recreate the evidence-backed slice on a current base and land through protected `main` before a release claim |
| Accessibility and responsive UX | #602 delivered base post-detail modal semantics; #605 adds selected-post refocus, collapsed/hidden/inert/CSS-invisible focus exclusion across both modal types, readable evidence separators, focused tests, and desktop/mobile Storybook screenshots | Land #605 through the protected gate, then complete screen-reader and authenticated Playwright acceptance on the exact release head |
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index 36f6fbb44..8a0d4d015 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -5,7 +5,7 @@ operator-facing control you can click before changing product CSS.
| Story | Operator next action | Token / module |
|---|---|---|
-| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, repeat issue, or topic-context influence. `TopicInfluenceAccepted` preserves exact ties, multiple membership, time states, uncertainty, and source actions; `EvidenceReady` shows the producer-contract unavailable state. `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover mobile, scoped-empty, explicit evidence-absence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard`, `TopicContextInfluence` |
+| `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, repeat issue, or topic-context influence. `TopicInfluenceAccepted` preserves exact ties, multiple membership, time states, uncertainty, and source actions; `EvidenceReady` shows the producer-contract unavailable state. `NarrowViewport`, `ExternalInformationEmpty`, `RequiredFactMissing`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, `ConcurrentLoading`, `LoadError`, and `VoiceSummaryLoadError` cover mobile, scoped-empty, explicit evidence-absence, analysis-pending, retryable failure, one accessible announcement for parallel loading, whole-dashboard transport failure, and independently retryable voice-summary failure. | `--color-dashboard-*`, `OperationsDashboard`, `TopicContextInfluence` |
| `Ask Agent/AnswerEvidenceTimeline` | Select an answer citation to focus its event card, select the card to return to the answer, then open its evidence or source post. `MissingObservedTime` keeps an absent event clock explicit and `NarrowViewport` verifies the single-column interaction. | `--color-accent-*`, `--radius-panel`, `--size-control-min`, `AskAnswerTimeline` |
| `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` |
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
@@ -17,6 +17,8 @@ operator-facing control you can click before changing product CSS.
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
| `Workspace/WorkspaceCalendar` | Read observed Naruon events, or open a commitment to land on that post. Fail-closed copy stays `이 범위의 일정을 아직 받을 수 없습니다`. | `--color-chip-border`, `WorkspaceCalendar`, `EvidenceStatusMark` |
| `Evidence/OntologyExplorer` | Distinguish Post, Person, Organization, and Team by shape and text, use the token-backed surface as a secondary cue, then open the exact-value table or cited evidence. Compare desktop, narrow, drawer, empty, truncated, denied, stale, and rejected states. | `--ontology-node-*-fill`, `OntologyExplorer` |
+| `Post/ProductEvidenceList` | Open the cited product span. If the identity is unresolved, review the product catalog before using the relationship. Compare catalog-linked and catalog-review-required states. | `--surface`, `--border`, `ProductEvidenceList` |
+| `Dashboard/VoiceTaxonomySummary` | Compare source and semantic classifications, note overlapping memberships, then review disagreements and records waiting for evidence. | `--surface`, `--border`, `VoiceTaxonomySummary` |
Repeated web objects must use `frontend/src/styles/tokens.css` and a module
under `frontend/src/components/`. Do not add a second Node package manager;
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 856809e6d..fbdd7cecf 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -121,6 +121,7 @@ import {
useLocale,
} from "./i18n";
import "./App.css";
+import { ProductEvidenceList } from "./components/ProductEvidenceList";
function orchestratorUnavailableMessage(err: unknown, action: string): string {
if (err instanceof BackendError && err.status === 503) {
@@ -2148,6 +2149,7 @@ function PostDetailPopup({
)}
+ {post.product_evidence?.length ? : null}
{(post.source_stage_code ||
post.source_detail_state_code ||
post.source_draft_code ||
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index e819a063a..a7717f8ff 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -122,6 +122,34 @@ export interface OperationsDashboardResponse {
cases: OperationsDashboardCase[];
}
+export interface VoiceTaxonomySummary {
+ total_eligible: number;
+ classified_unique: number;
+ multi_membership: number;
+ source_count: number;
+ derived_count: number;
+ unavailable: number;
+ disagreement: number;
+ counts_overlap: boolean;
+ category_memberships: Array<{
+ voice_concept_code: "voc" | "vocc" | "voco" | "vom" | "vop";
+ post_count: number;
+ eligible_percentage: number;
+ }>;
+}
+
+export async function fetchVoiceTaxonomySummary(
+ accessToken: string,
+ dateFrom = "",
+ dateTo = "",
+): Promise {
+ const query = new URLSearchParams();
+ if (dateFrom) query.set("date_from", dateFrom);
+ if (dateTo) query.set("date_to", dateTo);
+ const suffix = query.size ? `?${query.toString()}` : "";
+ return backendFetch(`/api/voice-taxonomy/summary${suffix}`, accessToken);
+}
+
export interface TopicContextDashboard {
status_code: "accepted" | "unavailable" | "not_applicable";
reason_code: string | null;
@@ -213,6 +241,17 @@ export interface PostKnownAt {
export interface PostDetail extends PostSummary {
post_body: string;
known_at?: PostKnownAt;
+ product_evidence?: ProductEvidence[];
+}
+
+export interface ProductEvidence {
+ mention_ordinal: number;
+ extracted_product_name: string;
+ resolution_status_code: "unique" | "missing" | "tie" | "unavailable";
+ canonical_product_name: string | null;
+ product_level_code: "product_group" | "product_model" | "variant" | "trade_item" | null;
+ evidence_text: string;
+ evidence_post_id: string;
}
export interface PostImageContent {
diff --git a/frontend/src/components/OperationsDashboard.stories.tsx b/frontend/src/components/OperationsDashboard.stories.tsx
index 665b716f0..fe130639d 100644
--- a/frontend/src/components/OperationsDashboard.stories.tsx
+++ b/frontend/src/components/OperationsDashboard.stories.tsx
@@ -165,3 +165,41 @@ export const LoadError: Story = {
await expect(canvas.getByRole("button", { name: "다시 시도" })).toBeVisible();
},
};
+
+export const ConcurrentLoading: Story = {
+ args: EvidenceReady.args,
+ render: () => undefined} />,
+ beforeEach: () => {
+ const fetchBeforeStory = globalThis.fetch;
+ globalThis.fetch = async () => new Promise(() => undefined);
+ return () => { globalThis.fetch = fetchBeforeStory; };
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getAllByRole("status")).toHaveLength(1);
+ await expect(canvas.getByRole("status")).toHaveTextContent("Loading voice evidence");
+ },
+};
+
+export const VoiceSummaryLoadError: Story = {
+ args: EvidenceReady.args,
+ render: () => undefined} />,
+ beforeEach: () => {
+ const fetchBeforeStory = globalThis.fetch;
+ globalThis.fetch = async (input) => {
+ if (String(input).includes("/api/dashboard")) {
+ return new Response(JSON.stringify(EvidenceReady.args!.data!), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ throw new Error("synthetic voice-summary transport failure");
+ };
+ return () => { globalThis.fetch = fetchBeforeStory; };
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.findByRole("alert")).resolves.toHaveTextContent("Voice evidence could not be loaded");
+ await expect(canvas.getByRole("button", { name: "Retry voice evidence" })).toBeVisible();
+ },
+};
diff --git a/frontend/src/components/OperationsDashboard.test.tsx b/frontend/src/components/OperationsDashboard.test.tsx
index df65027b3..35820d4b7 100644
--- a/frontend/src/components/OperationsDashboard.test.tsx
+++ b/frontend/src/components/OperationsDashboard.test.tsx
@@ -1,14 +1,23 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-import { fetchOperationsDashboard, type OperationsDashboardResponse } from "../api";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { fetchOperationsDashboard, fetchVoiceTaxonomySummary, type OperationsDashboardResponse } from "../api";
import { OperationsDashboard, OperationsDashboardView } from "./OperationsDashboard";
vi.mock("../api", async (importOriginal) => ({
...(await importOriginal()),
fetchOperationsDashboard: vi.fn(),
+ fetchVoiceTaxonomySummary: vi.fn(),
}));
+beforeEach(() => {
+ vi.mocked(fetchVoiceTaxonomySummary).mockReset().mockResolvedValue({
+ total_eligible: 0, classified_unique: 0, multi_membership: 0,
+ source_count: 0, derived_count: 0, unavailable: 0, disagreement: 0,
+ counts_overlap: true, category_memberships: [],
+ });
+});
+
const data: OperationsDashboardResponse = {
period_label: "2026-08-01–2026-08-25 · Event time",
total_post_count: 20,
@@ -179,6 +188,11 @@ describe("OperationsDashboardView", () => {
});
it("keeps period controls mounted while a changed period loads", async () => {
+ vi.mocked(fetchVoiceTaxonomySummary).mockResolvedValue({
+ total_eligible: 0, classified_unique: 0, multi_membership: 0,
+ source_count: 0, derived_count: 0, unavailable: 0, disagreement: 0,
+ counts_overlap: true, category_memberships: [],
+ });
vi.mocked(fetchOperationsDashboard)
.mockResolvedValueOnce(data)
.mockImplementationOnce(() => new Promise(() => undefined));
@@ -191,9 +205,55 @@ describe("OperationsDashboardView", () => {
});
it("requests the external scope at the API boundary", async () => {
- vi.mocked(fetchOperationsDashboard).mockResolvedValue(data);
+ vi.mocked(fetchVoiceTaxonomySummary).mockClear();
+ vi.mocked(fetchOperationsDashboard).mockReset().mockResolvedValue(data);
render( undefined} />);
await screen.findByText("5건");
expect(fetchOperationsDashboard).toHaveBeenCalledWith("synthetic-token", "", "", true);
+ expect(fetchVoiceTaxonomySummary).not.toHaveBeenCalled();
+ });
+
+ it("shows a failed voice summary and retries only that evidence", async () => {
+ vi.mocked(fetchOperationsDashboard).mockReset().mockResolvedValue(data);
+ vi.mocked(fetchVoiceTaxonomySummary)
+ .mockReset()
+ .mockRejectedValueOnce(new Error("synthetic transport failure"))
+ .mockResolvedValueOnce({
+ total_eligible: 0, classified_unique: 0, multi_membership: 0,
+ source_count: 0, derived_count: 0, unavailable: 0, disagreement: 0,
+ counts_overlap: true, category_memberships: [],
+ });
+ render( undefined} />);
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Voice evidence could not be loaded.");
+ await userEvent.click(screen.getByRole("button", { name: "Retry voice evidence" }));
+ expect(await screen.findByRole("heading", { name: "Voice evidence overview" })).toBeInTheDocument();
+ expect(fetchVoiceTaxonomySummary).toHaveBeenCalledTimes(2);
+ expect(fetchOperationsDashboard).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps voice evidence actionable when the dashboard request fails", async () => {
+ vi.mocked(fetchOperationsDashboard).mockReset().mockRejectedValue(new Error("synthetic dashboard failure"));
+ vi.mocked(fetchVoiceTaxonomySummary).mockReset().mockResolvedValue({
+ total_eligible: 0, classified_unique: 0, multi_membership: 0,
+ source_count: 0, derived_count: 0, unavailable: 0, disagreement: 0,
+ counts_overlap: true, category_memberships: [],
+ });
+ render( undefined} />);
+
+ expect(await screen.findByText("Dashboard 근거를 불러오지 못했습니다.")).toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: "Voice evidence overview" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "다시 시도" })).toBeInTheDocument();
+ });
+});
+
+describe("OperationsDashboard", () => {
+ it("announces concurrent dashboard and voice loading through one status region", () => {
+ vi.mocked(fetchOperationsDashboard).mockImplementation(() => new Promise(() => undefined));
+ vi.mocked(fetchVoiceTaxonomySummary).mockImplementation(() => new Promise(() => undefined));
+ render( undefined} />);
+ expect(screen.getAllByRole("status")).toHaveLength(1);
+ expect(screen.getByRole("status")).toHaveTextContent("Dashboard 근거를 불러오는 중입니다.");
+ expect(screen.getByRole("status")).toHaveTextContent("Loading voice evidence...");
});
});
diff --git a/frontend/src/components/OperationsDashboard.tsx b/frontend/src/components/OperationsDashboard.tsx
index 2b699505d..dbd8bfb26 100644
--- a/frontend/src/components/OperationsDashboard.tsx
+++ b/frontend/src/components/OperationsDashboard.tsx
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
-import { fetchOperationsDashboard, type OperationsDashboardResponse } from "../api";
+import { fetchOperationsDashboard, fetchVoiceTaxonomySummary, type OperationsDashboardResponse, type VoiceTaxonomySummary as VoiceSummary } from "../api";
+import { t } from "../i18n";
+import { VoiceTaxonomySummary } from "./VoiceTaxonomySummary";
function formatElapsed(seconds: number): string {
const days = Math.floor(seconds / 86_400);
@@ -35,6 +37,9 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP
const [periodEnd, setPeriodEnd] = useState("");
const [submittedPeriod, setSubmittedPeriod] = useState<[string, string]>(["", ""]);
const [retryCount, setRetryCount] = useState(0);
+ const [voiceSummary, setVoiceSummary] = useState(null);
+ const [voiceSummaryError, setVoiceSummaryError] = useState(false);
+ const [voiceRetryCount, setVoiceRetryCount] = useState(0);
useEffect(() => {
let active = true;
@@ -46,6 +51,17 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP
return () => { active = false; };
}, [accessToken, externalOnly, submittedPeriod, retryCount]);
+ useEffect(() => {
+ let active = true;
+ setVoiceSummary(null);
+ setVoiceSummaryError(false);
+ if (externalOnly) return () => { active = false; };
+ fetchVoiceTaxonomySummary(accessToken, ...submittedPeriod)
+ .then((value) => active && setVoiceSummary(value))
+ .catch(() => active && setVoiceSummaryError(true));
+ return () => { active = false; };
+ }, [accessToken, externalOnly, submittedPeriod, voiceRetryCount]);
+
return <>
{
event.preventDefault();
@@ -63,9 +79,21 @@ export function OperationsDashboard({ accessToken, externalOnly = false, onOpenP
) : data ? (
- ) : (
- Dashboard 근거를 불러오는 중입니다.
- )}
+ ) : null}
+ {!externalOnly && voiceSummary ? : null}
+ {!externalOnly && voiceSummaryError ? (
+
+ {t("Voice evidence overview")}
+ {t("Voice evidence could not be loaded.")}
+ setVoiceRetryCount((count) => count + 1)}>{t("Retry voice evidence")}
+
+ ) : null}
+ {(!data && !error) || (!externalOnly && !voiceSummary && !voiceSummaryError) ? (
+
+ {!data && !error ?
Dashboard 근거를 불러오는 중입니다.
: null}
+ {!externalOnly && !voiceSummary && !voiceSummaryError ?
{t("Loading voice evidence...")}
: null}
+
+ ) : null}
>;
}
diff --git a/frontend/src/components/ProductEvidenceList.stories.tsx b/frontend/src/components/ProductEvidenceList.stories.tsx
new file mode 100644
index 000000000..cc86dcf1a
--- /dev/null
+++ b/frontend/src/components/ProductEvidenceList.stories.tsx
@@ -0,0 +1,38 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { ProductEvidenceList } from "./ProductEvidenceList";
+
+const meta = {
+ title: "Post/ProductEvidenceList",
+ component: ProductEvidenceList,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const CatalogLinked: Story = {
+ args: {
+ products: [{
+ mention_ordinal: 0,
+ extracted_product_name: "Synthetic Model Q",
+ canonical_product_name: "Synthetic Model Q",
+ product_level_code: "product_model",
+ resolution_status_code: "unique",
+ evidence_text: "Synthetic Model Q was selected for the trial.",
+ evidence_post_id: "synthetic-post",
+ }],
+ },
+};
+
+export const CatalogReviewRequired: Story = {
+ args: {
+ products: [{
+ mention_ordinal: 0,
+ extracted_product_name: "Synthetic Model Q",
+ canonical_product_name: null,
+ product_level_code: null,
+ resolution_status_code: "tie",
+ evidence_text: "Synthetic Model Q was selected for the trial.",
+ evidence_post_id: "synthetic-post",
+ }],
+ },
+};
diff --git a/frontend/src/components/ProductEvidenceList.test.tsx b/frontend/src/components/ProductEvidenceList.test.tsx
new file mode 100644
index 000000000..3b946ca55
--- /dev/null
+++ b/frontend/src/components/ProductEvidenceList.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { ProductEvidenceList } from "./ProductEvidenceList";
+
+describe("ProductEvidenceList", () => {
+ it("shows the next catalog action only for an unresolved identity", () => {
+ render( );
+ expect(screen.getByRole("status")).toHaveTextContent("product catalog");
+ });
+});
diff --git a/frontend/src/components/ProductEvidenceList.tsx b/frontend/src/components/ProductEvidenceList.tsx
new file mode 100644
index 000000000..03352c2fb
--- /dev/null
+++ b/frontend/src/components/ProductEvidenceList.tsx
@@ -0,0 +1,23 @@
+import type { ProductEvidence } from "../api";
+import { t } from "../i18n";
+
+export function ProductEvidenceList({ products }: { products: ProductEvidence[] }) {
+ return (
+
+ {t("Product evidence")}
+
+
+ );
+}
diff --git a/frontend/src/components/VoiceTaxonomySummary.stories.tsx b/frontend/src/components/VoiceTaxonomySummary.stories.tsx
new file mode 100644
index 000000000..8665a6f6b
--- /dev/null
+++ b/frontend/src/components/VoiceTaxonomySummary.stories.tsx
@@ -0,0 +1,16 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { VoiceTaxonomySummary } from "./VoiceTaxonomySummary";
+
+const meta = { title: "Dashboard/VoiceTaxonomySummary", component: VoiceTaxonomySummary } satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const OverlappingEvidence: Story = { args: { data: {
+ total_eligible: 12, classified_unique: 5, multi_membership: 2,
+ source_count: 6, derived_count: 7, unavailable: 3, disagreement: 1,
+ counts_overlap: true,
+ category_memberships: [
+ { voice_concept_code: "voc", post_count: 5, eligible_percentage: 41.7 },
+ { voice_concept_code: "vom", post_count: 4, eligible_percentage: 33.3 },
+ ],
+} } };
diff --git a/frontend/src/components/VoiceTaxonomySummary.test.tsx b/frontend/src/components/VoiceTaxonomySummary.test.tsx
new file mode 100644
index 000000000..e46a3925b
--- /dev/null
+++ b/frontend/src/components/VoiceTaxonomySummary.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { VoiceTaxonomySummary } from "./VoiceTaxonomySummary";
+
+describe("VoiceTaxonomySummary", () => {
+ it("discloses overlapping counts and the next review action", () => {
+ render( );
+ expect(screen.getByRole("heading", { name: "Voice evidence overview" })).toBeInTheDocument();
+ expect(screen.getByText("Records in multiple voice categories")).toBeInTheDocument();
+ expect(screen.getByText("Records without voice evidence")).toBeInTheDocument();
+ expect(screen.getByText(/voice categories, so category counts can overlap/)).toBeInTheDocument();
+ expect(screen.getByText(/Review disagreements and records without voice evidence/)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/VoiceTaxonomySummary.tsx b/frontend/src/components/VoiceTaxonomySummary.tsx
new file mode 100644
index 000000000..adb067bad
--- /dev/null
+++ b/frontend/src/components/VoiceTaxonomySummary.tsx
@@ -0,0 +1,36 @@
+import type { VoiceTaxonomySummary as Summary } from "../api";
+import { t, tf } from "../i18n";
+
+const voiceLabels = {
+ voc: "Voice of Customer",
+ vocc: "Voice of Customer's customer",
+ voco: "Voice of Competitor",
+ vom: "Voice of Market",
+ vop: "Voice of Partner",
+} as const;
+
+export function VoiceTaxonomySummary({ data }: { data: Summary }) {
+ return (
+
+ {t("Voice evidence overview")}
+ {tf("Compare voice classifications across {count} visible records.", { count: data.total_eligible.toLocaleString() })}
+
+
{t("Recorded evidence")} {data.source_count.toLocaleString()}
+
{t("Stored semantic evidence")} {data.derived_count.toLocaleString()}
+
{t("Records in multiple voice categories")} {data.multi_membership.toLocaleString()}
+
{t("Needs review")} {data.disagreement.toLocaleString()}
+
{t("Records without voice evidence")} {data.unavailable.toLocaleString()}
+
+
+ {data.category_memberships.map((category) => (
+
+ {t(voiceLabels[category.voice_concept_code])} {" "}
+ {category.post_count.toLocaleString()} ({category.eligible_percentage.toFixed(1)}%)
+
+ ))}
+
+ {data.counts_overlap ? {t("One record may support several voice categories, so category counts can overlap.")}
: null}
+ {t("Review disagreements and records without voice evidence before using these classifications.")}
+
+ );
+}
diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts
index 3e1666a04..a3ef452c4 100644
--- a/frontend/src/i18n.test.ts
+++ b/frontend/src/i18n.test.ts
@@ -318,4 +318,16 @@ describe("locale-aware source labels", () => {
expect(t("Voice of Customer")).toBe(customerVoice);
expect(t("Public")).toBe(visibility);
});
+
+ it.each([
+ ["en", "Records in multiple voice categories", "Records without voice evidence"],
+ ["ko", "여러 글 유형에 해당하는 기록", "글 유형 근거가 없는 기록"],
+ ["zh", "属于多个声音类别的记录", "缺少声音证据的记录"],
+ ["ja", "複数の声カテゴリに該当する記録", "声の証拠がない記録"],
+ ["vi", "Bản ghi thuộc nhiều nhóm tiếng nói", "Bản ghi không có bằng chứng tiếng nói"],
+ ] as const)("keeps voice-summary metrics specific in %s", (locale, multiple, unavailable) => {
+ setLocale(locale);
+ expect(t("Records in multiple voice categories")).toBe(multiple);
+ expect(t("Records without voice evidence")).toBe(unavailable);
+ });
});
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index f2a363453..d44541d48 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -103,6 +103,8 @@ const TRANSLATIONS: Partial>> = {
Board: "게시판",
"Post body preview": "본문 미리보기",
"Post body": "본문",
+ "Product evidence": "제품 근거",
+ "Review the product catalog before using this relationship.": "이 관계를 사용하기 전에 제품 카탈로그를 확인하세요.",
"No post body.": "본문 없음",
"Authorized posts in this board.": "이 게시판에서 권한이 있는 글입니다.",
"Publication state": "공개 상태",
@@ -143,6 +145,19 @@ const TRANSLATIONS: Partial>> = {
"All visibility": "모든 공개 범위",
"Voice of Customer": "고객의 소리",
"Voice of Market": "시장의 소리",
+ "Voice of Customer's customer": "고객 고객사의 소리",
+ "Voice of Competitor": "경쟁사의 소리",
+ "Voice of Partner": "파트너의 소리",
+ "Voice evidence overview": "글 유형 근거 현황",
+ "Loading voice evidence...": "글 유형 근거를 불러오는 중입니다...",
+ "Voice evidence could not be loaded.": "글 유형 근거를 불러오지 못했습니다.",
+ "Retry voice evidence": "글 유형 근거 다시 시도",
+ "Compare voice classifications across {count} visible records.": "표시 가능한 기록 {count}건의 글 유형 근거를 비교합니다.",
+ "Records in multiple voice categories": "여러 글 유형에 해당하는 기록",
+ "Records without voice evidence": "글 유형 근거가 없는 기록",
+ "Needs review": "재검토 필요",
+ "One record may support several voice categories, so category counts can overlap.": "한 기록이 여러 글 유형을 뒷받침할 수 있어 항목별 건수는 중복될 수 있습니다.",
+ "Review disagreements and records without voice evidence before using these classifications.": "불일치와 글 유형 근거가 없는 기록을 확인한 뒤 분류 결과를 활용하세요.",
Public: "공개",
Private: "비공개",
"Newest first": "최신순",
@@ -624,6 +639,8 @@ const TRANSLATIONS: Partial>> = {
Board: "看板",
"Post body preview": "正文预览",
"Post body": "正文",
+ "Product evidence": "产品依据",
+ "Review the product catalog before using this relationship.": "使用此关系前,请检查产品目录。",
"No post body.": "无正文",
"Authorized posts in this board.": "此看板中的授权文章。",
"Publication state": "公开状态",
@@ -664,6 +681,19 @@ const TRANSLATIONS: Partial>> = {
"All visibility": "所有可见范围",
"Voice of Customer": "客户之声",
"Voice of Market": "市场之声",
+ "Voice of Customer's customer": "客户的客户之声",
+ "Voice of Competitor": "竞争对手之声",
+ "Voice of Partner": "合作伙伴之声",
+ "Voice evidence overview": "声音分类证据概览",
+ "Loading voice evidence...": "正在加载声音分类证据...",
+ "Voice evidence could not be loaded.": "无法加载声音分类证据。",
+ "Retry voice evidence": "重试声音分类证据",
+ "Compare voice classifications across {count} visible records.": "比较 {count} 条可见记录中的声音分类证据。",
+ "Records in multiple voice categories": "属于多个声音类别的记录",
+ "Records without voice evidence": "缺少声音证据的记录",
+ "Needs review": "需要复核",
+ "One record may support several voice categories, so category counts can overlap.": "一条记录可能支持多个声音类别,因此各类别计数可能重叠。",
+ "Review disagreements and records without voice evidence before using these classifications.": "使用这些分类前,请检查不一致项和缺少声音证据的记录。",
Public: "公开",
Private: "私有",
"Newest first": "最新优先",
@@ -1161,6 +1191,8 @@ const TRANSLATIONS: Partial>> = {
Board: "掲示板",
"Post body preview": "本文プレビュー",
"Post body": "本文",
+ "Product evidence": "製品の根拠",
+ "Review the product catalog before using this relationship.": "この関係を使用する前に製品カタログを確認してください。",
"No post body.": "本文なし",
"Authorized posts in this board.": "この掲示板で権限のある投稿です。",
"Publication state": "公開状態",
@@ -1201,6 +1233,19 @@ const TRANSLATIONS: Partial>> = {
"All visibility": "すべての公開範囲",
"Voice of Customer": "顧客の声",
"Voice of Market": "市場の声",
+ "Voice of Customer's customer": "顧客の顧客の声",
+ "Voice of Competitor": "競合の声",
+ "Voice of Partner": "パートナーの声",
+ "Voice evidence overview": "声分類の証拠概要",
+ "Loading voice evidence...": "声分類の証拠を読み込んでいます...",
+ "Voice evidence could not be loaded.": "声分類の証拠を読み込めませんでした。",
+ "Retry voice evidence": "声分類の証拠を再試行",
+ "Compare voice classifications across {count} visible records.": "表示可能な記録 {count} 件の声分類証拠を比較します。",
+ "Records in multiple voice categories": "複数の声カテゴリに該当する記録",
+ "Records without voice evidence": "声の証拠がない記録",
+ "Needs review": "要確認",
+ "One record may support several voice categories, so category counts can overlap.": "1件の記録が複数の声カテゴリを裏付ける場合があるため、カテゴリ件数は重複します。",
+ "Review disagreements and records without voice evidence before using these classifications.": "不一致と声の証拠がない記録を確認してから、分類結果を利用してください。",
Public: "公開",
Private: "非公開",
"Newest first": "新しい順",
@@ -1677,6 +1722,8 @@ const TRANSLATIONS: Partial>> = {
Board: "Bảng tin",
"Post body preview": "Xem trước nội dung",
"Post body": "Nội dung bài đăng",
+ "Product evidence": "Bằng chứng sản phẩm",
+ "Review the product catalog before using this relationship.": "Hãy kiểm tra danh mục sản phẩm trước khi sử dụng mối quan hệ này.",
"No post body.": "Không có nội dung",
"Authorized posts in this board.": "Các bài viết được cấp quyền trong bảng tin này.",
"Publication state": "Trạng thái công khai",
@@ -1717,6 +1764,19 @@ const TRANSLATIONS: Partial>> = {
"All visibility": "Tất cả phạm vi hiển thị",
"Voice of Customer": "Tiếng nói khách hàng",
"Voice of Market": "Tiếng nói thị trường",
+ "Voice of Customer's customer": "Tiếng nói khách hàng của khách hàng",
+ "Voice of Competitor": "Tiếng nói đối thủ",
+ "Voice of Partner": "Tiếng nói đối tác",
+ "Voice evidence overview": "Tổng quan bằng chứng phân loại tiếng nói",
+ "Loading voice evidence...": "Đang tải bằng chứng phân loại tiếng nói...",
+ "Voice evidence could not be loaded.": "Không thể tải bằng chứng phân loại tiếng nói.",
+ "Retry voice evidence": "Thử lại bằng chứng phân loại tiếng nói",
+ "Compare voice classifications across {count} visible records.": "So sánh bằng chứng phân loại tiếng nói trong {count} bản ghi có thể xem.",
+ "Records in multiple voice categories": "Bản ghi thuộc nhiều nhóm tiếng nói",
+ "Records without voice evidence": "Bản ghi không có bằng chứng tiếng nói",
+ "Needs review": "Cần xem lại",
+ "One record may support several voice categories, so category counts can overlap.": "Một bản ghi có thể hỗ trợ nhiều nhóm tiếng nói nên số lượng theo nhóm có thể trùng lặp.",
+ "Review disagreements and records without voice evidence before using these classifications.": "Hãy xem lại điểm bất đồng và bản ghi thiếu bằng chứng tiếng nói trước khi dùng các phân loại này.",
Public: "Công khai",
Private: "Riêng tư",
"Newest first": "Mới nhất trước",
diff --git a/lineageweave/chunking.py b/lineageweave/chunking.py
index edad0f59c..9d5bdb2b0 100644
--- a/lineageweave/chunking.py
+++ b/lineageweave/chunking.py
@@ -3,10 +3,8 @@
Embedding a whole flattened document as one vector buries a short relevant
passage under everything else in the same document -- the embedding
averages over content that has nothing to do with the query. Splitting
-into meaning-identifiable units first, embedding each unit, and comparing
-at the unit level (see :func:`chunked_max_similarity` in
-:mod:`lineageweave.embedding_client`) keeps a genuinely relevant unit's
-signal from being diluted by everything around it.
+into meaning-identifiable units first lets an external retrieval owner score
+an authorized, provenance-bearing unit instead of a flattened document.
Four unit types, each grounded in a boundary concept that already has a
name in the literature or a relevant standard rather than an arbitrary
diff --git a/lineageweave/data/lineageweave-kg.ttl b/lineageweave/data/lineageweave-kg.ttl
new file mode 100644
index 000000000..322a868b0
--- /dev/null
+++ b/lineageweave/data/lineageweave-kg.ttl
@@ -0,0 +1,546 @@
+@prefix : .
+@prefix owl: .
+@prefix rdf: .
+@prefix rdfs: .
+@prefix skos: .
+@prefix xsd: .
+@prefix prov: .
+@prefix org: .
+
+#################################################################
+# LineageWeave Knowledge Graph Ontology
+#
+# The formal OWL 2 Full / RDFS / SKOS vocabulary for the
+# `knowledge_graph_edge` table's node/edge types, the
+# `entity_relationship_type` / `person_side` / `corporate_entity_level`
+# / `voc_type` controlled vocabularies in migrations/, and
+# `post_summary_role.actor_type_code` (migrations/0012).
+#
+# ADR 0207 supersedes ADR 0157: the canonical namespace is the
+# repository-case spelling above -- the exact path GitHub Pages serves.
+# The lowercase namespace is a deprecated compatibility vocabulary
+# published beside this file as namespace-compatibility.ttl with
+# validated term-kind mappings; new producers must not mint lowercase
+# IRIs.
+#
+# `knowledge_graph_edge` (source_node_type_code, source_node_id) --
+# [edge_type_code] --> (target_node_type_code, target_node_id) is
+# already an RDF triple in shape (Cyganiak, Wood, & Lanthaler, 2014);
+# this file is the formal semantic layer over it -- PostgreSQL stays
+# the source of record. See docs/adr/0004-knowledge-graph-ontology.md
+# for the KG design rationale, docs/adr/0207-repository-case-ontology-namespace-canonical.md
+# for the namespace decision and SHACL boundary, and tests/test_ontology.py
+# for the round-trip check that every lookup code below actually exists
+# as a common_lookup_value row, and vice versa.
+#
+# Every controlled-vocabulary term carries a :lookupCode annotation
+# naming the exact `common_lookup_value.lookup_code` it corresponds to
+# -- that literal string, not the IRI fragment, is what the relational
+# schema stores. Column-projection datatype properties deliberately do
+# NOT carry :lookupCode: they project table columns, not governed
+# lookup rows, so there is nothing for the round-trip check to enforce
+# (the same discipline as the organization_name_resolution block below).
+#################################################################
+
+ a owl:Ontology ;
+ rdfs:label "LineageWeave Knowledge Graph Ontology" ;
+ rdfs:comment "Formal OWL 2 Full / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, corporate_entity_level, voc_type, and post_summary_role.actor_type_code controlled vocabularies. RDF reification for semantic project evidence is interpreted with OWL 2 RDF-Based Semantics rather than OWL 2 DL." .
+
+:lookupCode a owl:AnnotationProperty ;
+ rdfs:label "lookup code" ;
+ rdfs:comment "The exact common_lookup_value.lookup_code string this ontology term corresponds to." .
+
+#################################################################
+# Classes -- node_type
+#################################################################
+
+:Post a owl:Class ;
+ rdfs:label "Post" ;
+ rdfs:comment "A source_post row: one record typed by the voc_type scheme (:postTypeScheme) -- Voice of Customer, Customer's Customer, Competitor, Market, or Partner." ;
+ :lookupCode "node_post" .
+
+:Person a owl:Class ;
+ rdfs:label "Person" ;
+ rdfs:comment "A cataloged_person row: a Keyman mentioned in one or more posts." ;
+ :lookupCode "node_person" .
+
+:OurSidePerson a owl:Class ;
+ rdfs:subClassOf :Person ;
+ rdfs:label "Our-side person" ;
+ :lookupCode "our_side" .
+
+:CounterpartyPerson a owl:Class ;
+ rdfs:subClassOf :Person ;
+ rdfs:label "Counterparty person" ;
+ :lookupCode "counterparty" .
+
+# A person side is exactly one of our-side or counterparty (the seeded
+# person_side vocabulary has no third value), so the two subclasses are
+# declared disjoint: a reasoner must never infer both from one row, and
+# the SHACL shapes graph carries the closed-world complement.
+:OurSidePerson owl:disjointWith :CounterpartyPerson .
+
+:CorporateEntity a owl:Class ;
+ rdfs:subClassOf skos:Concept ;
+ rdfs:label "Corporate entity" ;
+ rdfs:comment "A corporate_entity row. Also a skos:Concept so the self-referencing parent_entity_id hierarchy (e.g. Group -> Company -> Plant) is expressible with skos:broader/skos:narrower on instances." ;
+ :lookupCode "node_corporate_entity" .
+
+:Team a owl:Class ;
+ rdfs:subClassOf org:OrganizationalUnit ;
+ rdfs:label "Team" ;
+ rdfs:comment "A cataloged_team row: a named company sub-unit (ADR 0009) with a stable team_id, distinct from :RoleActorTeam (ADR 0007's per-row actor_type_code classification) the same way :Person is distinct from :RoleActorPerson." ;
+ :lookupCode "node_team" .
+
+#################################################################
+# Object properties -- edge_type (knowledge_graph_edge.edge_type_code)
+#################################################################
+
+:mentionedIn a owl:ObjectProperty ;
+ rdfs:domain :Person ;
+ rdfs:range :Post ;
+ rdfs:label "mentioned in" ;
+ rdfs:comment "A person is named by a post (post_person_mention); this is the canonical direction stored by knowledge_graph_edge." ;
+ :lookupCode "edge_mention" .
+
+# Keep the natural-language inverse available to RDF consumers without
+# assigning the relational lookup code to two different properties.
+:mentions a owl:ObjectProperty ;
+ rdfs:domain :Post ;
+ rdfs:range :Person ;
+ rdfs:label "mentions" ;
+ owl:inverseOf :mentionedIn .
+
+:affiliatedWith a owl:ObjectProperty ;
+ rdfs:domain :Person ;
+ rdfs:range :CorporateEntity ;
+ rdfs:label "affiliated with" ;
+ rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ;
+ :lookupCode "edge_affiliation" .
+
+# Bidirectional query support for affiliations: consumers can traverse
+# entity -> people without a second stored edge. Like :mentions above,
+# the inverse stays un-coded so one lookup_code keeps naming exactly one
+# stored property.
+:hasAffiliate a owl:ObjectProperty ;
+ rdfs:domain :CorporateEntity ;
+ rdfs:range :Person ;
+ rdfs:label "has affiliate" ;
+ owl:inverseOf :affiliatedWith .
+
+:coMentionedWith a owl:ObjectProperty, owl:SymmetricProperty ;
+ rdfs:domain :Person ;
+ rdfs:range :Person ;
+ rdfs:label "co-mentioned with" ;
+ rdfs:comment "Two people named in the same post -- symmetric by construction." ;
+ :lookupCode "edge_co_mention" .
+
+#################################################################
+# Object properties -- ADR 0009 cross-post identity resolution edges.
+# Kept distinct from :mentionedIn/:affiliatedWith (not reused with a
+# broadened domain/range) so an edge_type_code alone always tells you
+# which node types it connects -- stating rdfs:domain for the same
+# property twice (once :Person, once :Team) would make RDFS entail
+# every :mentionedIn subject is BOTH a :Person and a :Team, which is false.
+#################################################################
+
+:mentionsTeam a owl:ObjectProperty ;
+ rdfs:domain :Team ;
+ rdfs:range :Post ;
+ rdfs:label "mentioned in post" ;
+ rdfs:comment "A cataloged team is named by a post (post_team_mention)." ;
+ :lookupCode "edge_mention_team" .
+
+:teamAffiliatedWith a owl:ObjectProperty ;
+ rdfs:domain :Team ;
+ rdfs:range :CorporateEntity ;
+ rdfs:label "team affiliated with" ;
+ rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ;
+ :lookupCode "edge_team_affiliation" .
+
+:mentionsOrganization a owl:ObjectProperty ;
+ rdfs:domain :CorporateEntity ;
+ rdfs:range :Post ;
+ rdfs:label "mentioned in post" ;
+ rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ;
+ :lookupCode "edge_mention_organization" .
+
+#################################################################
+# Object properties -- entity_relationship_type
+# (post_counterparty_entity.relationship_type_code)
+#################################################################
+
+:hasVocRelationship a owl:ObjectProperty ;
+ rdfs:domain :Post ; rdfs:range :CorporateEntity ;
+ rdfs:label "has Voice-of-Customer relationship" ;
+ :lookupCode "rel_voc" .
+
+:hasVomRelationship a owl:ObjectProperty ;
+ rdfs:domain :Post ; rdfs:range :CorporateEntity ;
+ rdfs:label "has Voice-of-Market relationship" ;
+ :lookupCode "rel_vom" .
+
+:hasVopRelationship a owl:ObjectProperty ;
+ rdfs:domain :Post ; rdfs:range :CorporateEntity ;
+ rdfs:label "has Voice-of-Partner relationship" ;
+ :lookupCode "rel_vop" .
+
+:hasVoccRelationship a owl:ObjectProperty ;
+ rdfs:domain :Post ; rdfs:range :CorporateEntity ;
+ rdfs:label "has Voice-of-Customer's-Customer relationship" ;
+ :lookupCode "rel_vocc" .
+
+:hasVocoRelationship a owl:ObjectProperty ;
+ rdfs:domain :Post ; rdfs:range :CorporateEntity ;
+ rdfs:label "has Voice-of-Competitor relationship" ;
+ :lookupCode "rel_voco" .
+
+:hasVosRelationship a owl:ObjectProperty ;
+ rdfs:domain :Post ; rdfs:range :CorporateEntity ;
+ rdfs:label "has Voice-of-Supplier relationship" ;
+ :lookupCode "rel_vos" .
+
+#################################################################
+# Datatype properties -- node attribute projections.
+#
+# These project real source columns (source_post.post_title /
+# post_body / created_at / updated_at / event_occurred_at;
+# cataloged_person.person_name / last_known_job_title;
+# corporate_entity.corporate_entity_code / entity_name). No property
+# is minted for a column that does not exist. Shared timestamps carry
+# NO rdfs:domain on purpose: two rdfs:domain statements would entail
+# every subject belongs to BOTH classes -- the multi-domain trap the
+# cross-post edge block above already avoids. Per-class cardinality
+# and datatype constraints live in the SHACL shapes graph
+# (lineageweave-kg-shapes.ttl), which validates projected data
+# closed-world where OWL's open world deliberately will not
+# (Knublauch & Kontokostas, 2017).
+#################################################################
+
+:postTitle a owl:DatatypeProperty ;
+ rdfs:domain :Post ;
+ rdfs:range xsd:string ;
+ rdfs:label "post title" ;
+ rdfs:comment "source_post.post_title -- the authoring application's title text." .
+
+:postBody a owl:DatatypeProperty ;
+ rdfs:domain :Post ;
+ rdfs:range xsd:string ;
+ rdfs:label "post body" ;
+ rdfs:comment "source_post.post_body -- the preserved source representation, never flattened into one opaque string by derived views." .
+
+:eventOccurredAt a owl:DatatypeProperty ;
+ rdfs:domain :Post ;
+ rdfs:range xsd:dateTime ;
+ rdfs:label "event occurred at" ;
+ rdfs:comment "source_post.event_occurred_at (migrations 0183) -- the business event instant Global Ask time filters bind to, falling back to created_at only when missing (ADR 0150)." .
+
+:personName a owl:DatatypeProperty ;
+ rdfs:domain :Person ;
+ rdfs:range xsd:string ;
+ rdfs:label "person name" ;
+ rdfs:comment "cataloged_person.person_name -- Keyman extraction tests the raw organization name before any abbreviation rewrite so a rewrite cannot turn an existing tie into an apparent creation miss (ADR 0026)." .
+
+:lastKnownJobTitle a owl:DatatypeProperty ;
+ rdfs:domain :Person ;
+ rdfs:range xsd:string ;
+ rdfs:label "last known job title" ;
+ rdfs:comment "cataloged_person.last_known_job_title (migrations 0013) -- a stated title is real same-name disambiguation evidence even when no affiliation row exists." .
+
+:entityName a owl:DatatypeProperty ;
+ rdfs:domain :CorporateEntity ;
+ rdfs:range xsd:string ;
+ rdfs:label "entity name" ;
+ rdfs:comment "corporate_entity.entity_name -- the human-readable hierarchy label; corporate similarity results stay unique/miss/tie over this name (ADR 0026)." .
+
+:entityCode a owl:DatatypeProperty ;
+ rdfs:domain :CorporateEntity ;
+ rdfs:range xsd:string ;
+ rdfs:label "entity code" ;
+ rdfs:comment "corporate_entity.corporate_entity_code -- the short corp code carried at login time, distinct from the display name." .
+
+# Shared record timestamps apply to every KG node kind, so they declare
+# no domain (see the block comment above); the shapes graph pins them
+# per class.
+:createdAt a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime ;
+ rdfs:label "created at" ;
+ rdfs:comment "Record creation instant shared across node kinds (each source table's created_at); no rdfs:domain because multiple domains would entail impossible co-membership." .
+
+:updatedAt a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime ;
+ rdfs:label "updated at" ;
+ rdfs:comment "Record last-write instant shared across node kinds (e.g. source_post.updated_at); null updated-at falls back to created_at at import boundaries." .
+
+#################################################################
+# SKOS -- voc_type (post type classification)
+#
+# The five-value VOC source vocabulary migrations/0042 governs. There
+# are exactly five seeded codes: vos exists only as a relationship type
+# (rel_vos above), never as a post type, so no Voice-of-Supplier concept
+# belongs here. Adding "voc_type" to the ontology-covered categories
+# puts these codes under tests/test_ontology.py's round-trip check --
+# closing the previously documented expected gap.
+#################################################################
+
+:postTypeScheme a skos:ConceptScheme ;
+ rdfs:label "Post type scheme" ;
+ rdfs:comment "Voice-based classification of what a source post records, per the governed five-value voc_type lookup category (migrations/0042)." .
+
+:voiceOfCustomerType a skos:Concept ;
+ skos:inScheme :postTypeScheme ;
+ skos:prefLabel "Voice of Customer"@en ;
+ rdfs:comment "A customer's own voice about their experience." ;
+ :lookupCode "voc" .
+
+:voiceOfCustomersCustomerType a skos:Concept ;
+ skos:inScheme :postTypeScheme ;
+ skos:prefLabel "Voice of Customer's Customer"@en ;
+ rdfs:comment "The voice of the customer's downstream customer." ;
+ :lookupCode "vocc" .
+
+:voiceOfCompetitorType a skos:Concept ;
+ skos:inScheme :postTypeScheme ;
+ skos:prefLabel "Voice of Competitor"@en ;
+ rdfs:comment "Market intelligence sourced from a competitor." ;
+ :lookupCode "voco" .
+
+:voiceOfMarketType a skos:Concept ;
+ skos:inScheme :postTypeScheme ;
+ skos:prefLabel "Voice of Market"@en ;
+ rdfs:comment "General market signal not attributable to one account or partner." ;
+ :lookupCode "vom" .
+
+:voiceOfPartnerType a skos:Concept ;
+ skos:inScheme :postTypeScheme ;
+ skos:prefLabel "Voice of Partner"@en ;
+ rdfs:comment "A partner organization's voice." ;
+ :lookupCode "vop" .
+
+#################################################################
+# SKOS -- corporate_entity_level (Group -> Company -> Plant)
+#################################################################
+
+:corporateEntityLevelScheme a skos:ConceptScheme ;
+ rdfs:label "Corporate entity level scheme" ;
+ rdfs:comment "The Acme Group -> Acme Electronics Korea -> Acme Electronics Gwangju Plant kind of level, ordered broadest first." .
+
+:GroupLevel a skos:Concept ;
+ skos:inScheme :corporateEntityLevelScheme ;
+ skos:prefLabel "Group"@en ;
+ :lookupCode "group" .
+
+:CompanyLevel a skos:Concept ;
+ skos:inScheme :corporateEntityLevelScheme ;
+ skos:broader :GroupLevel ;
+ skos:prefLabel "Company"@en ;
+ :lookupCode "company" .
+
+:PlantLevel a skos:Concept ;
+ skos:inScheme :corporateEntityLevelScheme ;
+ skos:broader :CompanyLevel ;
+ skos:prefLabel "Plant"@en ;
+ :lookupCode "plant" .
+
+:GroupLevel skos:narrower :CompanyLevel .
+:CompanyLevel skos:narrower :PlantLevel .
+
+#################################################################
+# Classes -- prov_agent_type (post_summary_role.actor_type_code)
+#
+# A post's R&R (roles & responsibilities) actor is not always a person
+# -- business correspondence routinely names an organization acting
+# in its own name ("당사" [our company], "Demo Corp"). Grounded
+# directly in W3C PROV-O (Lebo, Sahoo, & McGuinness,
+# 2013): prov:Agent is the general acting-party class, with prov:Person
+# and prov:Organization its two recognized subclasses. These are
+# distinct from :Person / :OurSidePerson / :CounterpartyPerson above:
+# node_type's :Person is a cataloged_person row with a stable person_id
+# a Keyman panel links to; an R&R actor is a free-text name with no
+# cataloged identity of its own (it may not even resolve to a Keyman).
+#
+# A third, meso-level case real data surfaced: a named sub-unit of a
+# company ("설계팀" [design team]) is neither prov:Person nor the
+# prov:Organization itself -- it is the company's own internal
+# structure. PROV-O has no such class; the W3C Organization Ontology
+# (Reynolds, 2014) does: org:OrganizationalUnit, "used to represent
+# division of a particular organization into sub-organizational units,"
+# linked to its parent via org:unitOf. See docs/adr/0007-team-actor-type.md.
+#
+# Keyman job titles and industry sectors remain free-text columns with
+# no governed lookup category, so no SKOS scheme is invented for them
+# here (ADR 0207 decision 8 tracks that gap rather than fabricating
+# vocabulary).
+#################################################################
+
+:RoleActorPerson a owl:Class ;
+ rdfs:subClassOf prov:Person ;
+ rdfs:label "Role actor (person)" ;
+ rdfs:comment "An R&R actor that is a named individual, per prov:Person." ;
+ :lookupCode "prov_person" .
+
+:RoleActorOrganization a owl:Class ;
+ rdfs:subClassOf prov:Organization ;
+ rdfs:label "Role actor (organization)" ;
+ rdfs:comment "An R&R actor that is an organization acting in its own name, per prov:Organization." ;
+ :lookupCode "prov_organization" .
+
+:RoleActorTeam a owl:Class ;
+ rdfs:subClassOf org:OrganizationalUnit ;
+ rdfs:label "Role actor (team)" ;
+ rdfs:comment "An R&R actor that is a named sub-unit of a company (e.g. 설계팀), per org:OrganizationalUnit -- not the company itself." ;
+ :lookupCode "prov_team" .
+
+#################################################################
+# organization_name_resolution (raw/canonical organization-name pairs)
+#
+# ADR 0008: an abbreviated/slang organization mention (e.g. "AGP")
+# is resolved to its full canonical name ("Aurora Grid Power") and
+# cross-verified via external search before being trusted. This is not
+# a new KG node/edge type -- no new :lookupCode term is declared here,
+# since organization_name_resolution's columns are not a
+# common_lookup_value category (there is nothing for
+# tests/test_ontology.py's round-trip check to enforce). Documented
+# here for the Ontology/Semantic-Layer grounding itself:
+# `organization_name_resolution.raw_organization_name` corresponds to
+# SKOS `skos:altLabel` (an alternative label -- an abbreviation is
+# exactly this) and `resolved_organization_name` to `skos:prefLabel`
+# (the single preferred/canonical label), per Miles & Bechhofer (2009).
+#################################################################
+# Semantic project extraction (ADR 0036). These resources are distinct from
+# imported grouping fields: a post may mention a project without carrying a
+# project field, and the mention keeps evidence/confidence for review.
+:Project a owl:Class ;
+ rdfs:label "Project"@en ;
+ rdfs:comment "A business project referred to by a source post."@en .
+
+:ProjectMention a owl:Class ;
+ rdfs:subClassOf rdf:Statement,
+ [ a owl:Restriction ; owl:onProperty rdf:subject ; owl:allValuesFrom :Post ],
+ [ a owl:Restriction ; owl:onProperty rdf:predicate ; owl:hasValue :mentionsProject ],
+ [ a owl:Restriction ; owl:onProperty rdf:object ; owl:allValuesFrom :Project ] ;
+ rdfs:label "Project mention"@en ;
+ rdfs:comment "An evidence-backed, RDF-reified assertion that a post refers to a project; rdf:subject identifies the post, rdf:predicate is :mentionsProject, and rdf:object identifies the project."@en .
+
+:mentionsProject a owl:ObjectProperty ;
+ rdfs:domain :Post ;
+ rdfs:range :Project .
+
+:projectEvidence a owl:DatatypeProperty ;
+ rdfs:domain :ProjectMention ;
+ rdfs:range xsd:string .
+
+:semanticConfidence a owl:DatatypeProperty ;
+ rdfs:domain :ProjectMention ;
+ rdfs:range xsd:decimal .
+
+#################################################################
+# Evidence-grounded operations Dashboard (ADR 0206).
+#
+# These are governed read-projection terms over operations_case_* rows,
+# not knowledge_graph_edge aliases. Each reified fact retains its cited
+# source post through prov:wasDerivedFrom. Untyped legacy external-relation
+# rows stay outside the typed relation projection.
+#################################################################
+
+:OperationsCase a owl:Class ;
+ rdfs:subClassOf prov:Entity ;
+ rdfs:label "Operations case"@en .
+
+:ClaimInvestigation a owl:Class ;
+ rdfs:subClassOf :OperationsCase ;
+ rdfs:label "Claim investigation"@en .
+
+:RebidHandover a owl:Class ;
+ rdfs:subClassOf :OperationsCase ;
+ rdfs:label "Rebid or handover"@en .
+
+:ExternalInformation a owl:Class ;
+ rdfs:subClassOf :OperationsCase ;
+ rdfs:label "External information"@en .
+
+:RepeatIssue a owl:Class ;
+ rdfs:subClassOf :OperationsCase ;
+ rdfs:label "Repeat issue"@en .
+
+:OperationsCaseFact a owl:Class ;
+ rdfs:subClassOf rdf:Statement, prov:Entity ;
+ rdfs:label "Operations case fact"@en .
+
+:Product a owl:Class ;
+ rdfs:label "Product"@en ;
+ rdfs:comment "A governed product catalog identity at group, model, variant, or trade-item level."@en .
+
+:ProductMention a owl:Class ;
+ rdfs:label "Product mention"@en ;
+ rdfs:comment "A source-span-bound product mention with a fail-closed catalog resolution outcome."@en .
+
+:mentionsProduct a owl:ObjectProperty ;
+ rdfs:domain :ProductMention ; rdfs:range :Product .
+
+:extractedProductName a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:productResolutionStatus a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:evidenceInputDigest a owl:DatatypeProperty ;
+ rdfs:domain :ProductMention ; rdfs:range xsd:string .
+
+:PostVoiceClassificationAssertion a owl:Class ;
+ rdfs:label "Post voice classification assertion"@en .
+
+:OrganizationVoiceRelationshipAssertion a owl:Class ;
+ rdfs:label "Organization voice relationship assertion"@en .
+
+:voiceConceptCode a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:voiceAssertionStatus a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:voiceEvidenceDigest a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:sourceRevisionDigest a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:evidenceSpanStart a owl:DatatypeProperty ;
+ rdfs:range xsd:integer .
+
+:evidenceSpanEnd a owl:DatatypeProperty ;
+ rdfs:range xsd:integer .
+
+:validFrom a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime .
+
+:validTo a owl:DatatypeProperty ;
+ rdfs:range xsd:dateTime .
+
+:orchestratorModelReceipt a owl:DatatypeProperty ;
+ rdfs:range xsd:string .
+
+:Order a owl:Class ; rdfs:label "Order"@en .
+:SalesContext a owl:Class ; rdfs:label "Sales context"@en .
+:BusinessManagementContext a owl:Class ; rdfs:label "Business-management context"@en .
+
+:relatesToOrder a owl:ObjectProperty ;
+ rdfs:domain :ExternalInformation ; rdfs:range :Order .
+
+:relatesToProject a owl:ObjectProperty ;
+ rdfs:domain :ExternalInformation ; rdfs:range :Project .
+
+:relatesToSales a owl:ObjectProperty ;
+ rdfs:domain :ExternalInformation ; rdfs:range :SalesContext .
+
+:relatesToBusinessManagement a owl:ObjectProperty ;
+ rdfs:domain :ExternalInformation ; rdfs:range :BusinessManagementContext .
+
+:hasOperationsFact a owl:ObjectProperty ;
+ rdfs:domain :OperationsCase ; rdfs:range :OperationsCaseFact .
+
+:factTypeCode a owl:DatatypeProperty ;
+ rdfs:domain :OperationsCaseFact ; rdfs:range xsd:string .
+
+:factValue a owl:DatatypeProperty ;
+ rdfs:domain :OperationsCaseFact ; rdfs:range xsd:string .
diff --git a/lineageweave/embedding_backfill.py b/lineageweave/embedding_backfill.py
index 0e4bc7283..bfe27f55e 100644
--- a/lineageweave/embedding_backfill.py
+++ b/lineageweave/embedding_backfill.py
@@ -10,21 +10,37 @@
from .llm_context import build_post_llm_metadata
_SELECT_UNITS_SQL = """
-select unit.post_content_unit_id, unit.unit_text, unit.unit_index,
- post.post_id, post.author_account_id, post.source_process_unit_code,
- post.source_author_code, post.source_company_code,
- post.source_customer_code, post.source_project_code,
- post.source_sales_pool_code, entity.corporate_entity_code
- from post_content_unit unit
- join source_post post using (post_id)
- left join corporate_entity entity using (corporate_entity_id)
- where nullif(btrim(unit.unit_text), '') is not null
- and not exists (
- select 1 from post_content_embedding existing
- where existing.post_content_unit_id = unit.post_content_unit_id
- )
- order by post.created_at, post.post_id, unit.unit_index
- limit $1
+with bounded_candidates as materialized (
+ select unit.post_content_unit_id, unit.unit_text, unit.unit_index,
+ post.created_at as post_created_at,
+ post.post_id, post.author_account_id, post.source_process_unit_code,
+ post.source_author_code, post.source_company_code,
+ post.source_customer_code, post.source_project_code,
+ post.source_sales_pool_code, entity.corporate_entity_code
+ from post_content_unit unit
+ join source_post post using (post_id)
+ left join corporate_entity entity using (corporate_entity_id)
+ where nullif(btrim(unit.unit_text), '') is not null
+ and not exists (
+ select 1 from post_content_embedding existing
+ where existing.post_content_unit_id = unit.post_content_unit_id
+ )
+ order by post.created_at, post.post_id, unit.unit_index
+ limit $2
+), candidates as (
+ select bounded_candidates.*,
+ row_number() over (
+ order by post_created_at, post_id, unit_index
+ ) as candidate_ordinal,
+ sum(octet_length(unit_text) + 1) over (
+ order by post_created_at, post_id, unit_index
+ ) as cumulative_text_bytes
+ from bounded_candidates
+)
+select * from candidates
+ where candidate_ordinal = 1
+ or (cumulative_text_bytes <= $1 and candidate_ordinal <= $2)
+ order by cumulative_text_bytes
"""
@@ -32,19 +48,21 @@ async def backfill_post_content_embeddings(
conn: Any,
embedding_client: ContextualOrchestratorEmbeddingClient,
*,
- input_limit: int,
+ max_request_body_bytes: int,
+ max_inputs: int,
) -> dict[str, int | str]:
"""Embed one explicitly bounded unit set and atomically persist the complete batch.
The provider call finishes and validates every vector before the transaction
starts. Consequently a provider failure cannot delete or partially replace a
- persisted embedding. ``input_limit`` is an operator-supplied work selection,
- not a locally invented provider limit; contextual-orchestrator remains the
- owner of provider request partitioning.
+ persisted embedding. The candidate query and final prefix are both bounded
+ by contextual-orchestrator's advertised request-body ceiling.
"""
- if input_limit < 1:
- raise ValueError("input_limit must be positive")
- rows = list(await conn.fetch(_SELECT_UNITS_SQL, input_limit))
+ if max_request_body_bytes < 1:
+ raise ValueError("max_request_body_bytes must be positive")
+ if max_inputs < 1:
+ raise ValueError("max_inputs must be positive")
+ rows = list(await conn.fetch(_SELECT_UNITS_SQL, max_request_body_bytes, max_inputs))
if not rows:
return {"selected_units": 0, "persisted_units": 0, "dimension_values": 0}
@@ -74,6 +92,28 @@ async def backfill_post_content_embeddings(
}
)
+ selected_count = 0
+ lower = 1
+ upper = len(rows)
+ while lower <= upper:
+ candidate_count = (lower + upper) // 2
+ body_size = embedding_client.batch_request_body_size(
+ texts[:candidate_count],
+ input_attributions=attributions[:candidate_count],
+ input_metadata=metadata[:candidate_count],
+ )
+ if body_size > max_request_body_bytes:
+ upper = candidate_count - 1
+ else:
+ selected_count = candidate_count
+ lower = candidate_count + 1
+ if selected_count == 0:
+ raise ValueError("one semantic unit exceeds the advertised embedding request ceiling")
+ rows = rows[:selected_count]
+ texts = texts[:selected_count]
+ metadata = metadata[:selected_count]
+ attributions = attributions[:selected_count]
+
vectors = await asyncio.to_thread(
embedding_client.embed_many,
texts,
diff --git a/lineageweave/embedding_client.py b/lineageweave/embedding_client.py
index 770c142ed..fc55dead8 100644
--- a/lineageweave/embedding_client.py
+++ b/lineageweave/embedding_client.py
@@ -15,8 +15,7 @@
from collections.abc import Mapping
from typing import Protocol
-from .chunking import Chunk, chunk_by_paragraph
-from .http_client import get_json, post_json
+from .http_client import get_json, json_request_body, post_json
class EmbeddingClient(Protocol):
@@ -46,9 +45,9 @@ class OpenAiCompatibleEmbeddingClient:
available = True
- def __init__(self, base_url: str, api_key: str, model: str | None = None, *, timeout: float = 30.0) -> None:
+ def __init__(self, base_url: str, api_key: str, *, timeout: float = 30.0) -> None:
self._delegate = ContextualOrchestratorEmbeddingClient(
- base_url, api_key, model, timeout=timeout
+ base_url, api_key, timeout=timeout
)
def embed(self, text: str) -> list[float]:
@@ -70,7 +69,6 @@ def __init__(
self,
base_url: str,
api_key: str,
- model: str | None = None,
*,
timeout: float = 60.0,
poll_interval: float = 0.25,
@@ -79,7 +77,7 @@ def __init__(
if not self._base_url.endswith("/v1"):
self._base_url = f"{self._base_url}/v1"
self._api_key = api_key
- self._model = model or None
+ self._model: str | None = None
self._timeout = timeout
self._poll_interval = poll_interval
@@ -102,17 +100,11 @@ def embed_many(
if input_metadata is not None and len(input_metadata) != len(texts):
raise ValueError("input_metadata must align with texts")
headers = {"authorization": f"Bearer {self._api_key}"}
- payload = {
- "inputs": texts,
- "endpoint": "/v1/embeddings",
- "metadata": {"service": "lineageweave", "channel": "post_content_embedding"},
- }
- if input_attributions is not None:
- payload["input_attributions"] = [dict(value) for value in input_attributions]
- if input_metadata is not None:
- payload["input_metadata"] = [dict(value) for value in input_metadata]
- if self._model is not None:
- payload["model"] = self._model
+ payload = self.batch_payload(
+ texts,
+ input_attributions=input_attributions,
+ input_metadata=input_metadata,
+ )
response = post_json(
f"{self._base_url}/batch/embeddings",
payload,
@@ -122,16 +114,29 @@ def embed_many(
self._bind_model(response)
batch_id = response.get("batch_id")
if isinstance(batch_id, str) and batch_id:
- deadline = time.monotonic() + self._timeout
+ job_retention_ms = response.get("job_retention_ms")
+ if type(job_retention_ms) is not int or job_retention_ms < 1:
+ raise ValueError("embedding batch did not declare result retention")
+ deadline = time.monotonic() + job_retention_ms / 1000
while True:
vectors = self._vectors(response, len(texts))
if vectors is not None:
return vectors
if response.get("status") in {"failed", "cancelled", "rejected"}:
- raise RuntimeError("embedding batch did not complete")
+ failure = response.get("failure")
+ failure_code = (
+ failure.get("provider_code") or failure.get("error_type")
+ if isinstance(failure, dict)
+ else None
+ )
+ suffix = f": {failure_code}" if failure_code else ""
+ raise RuntimeError(f"embedding batch did not complete{suffix}")
if time.monotonic() >= deadline:
raise TimeoutError("embedding batch timed out")
- time.sleep(self._poll_interval)
+ poll_after_ms = response.get("poll_after_ms")
+ if type(poll_after_ms) is not int or poll_after_ms < 1:
+ raise ValueError("embedding batch did not declare a polling cadence")
+ time.sleep(poll_after_ms / 1000)
response = get_json(
f"{self._base_url}/batch/embeddings/{batch_id}",
headers=headers,
@@ -145,6 +150,71 @@ def embed_many(
raise ValueError("embedding response did not contain a complete vector batch")
return vectors
+ def batch_payload(
+ self,
+ texts: list[str],
+ *,
+ input_attributions: list[Mapping[str, object]] | None = None,
+ input_metadata: list[Mapping[str, object]] | None = None,
+ ) -> dict[str, object]:
+ """Build the exact provider-neutral bulk request document."""
+ payload: dict[str, object] = {
+ "inputs": texts,
+ "endpoint": "/v1/embeddings",
+ "metadata": {"service": "lineageweave", "channel": "post_content_embedding"},
+ }
+ if input_attributions is not None:
+ payload["input_attributions"] = [dict(value) for value in input_attributions]
+ if input_metadata is not None:
+ payload["input_metadata"] = [dict(value) for value in input_metadata]
+ if self._model is not None:
+ payload["model"] = self._model
+ return payload
+
+ def batch_request_body_size(
+ self,
+ texts: list[str],
+ *,
+ input_attributions: list[Mapping[str, object]] | None = None,
+ input_metadata: list[Mapping[str, object]] | None = None,
+ ) -> int:
+ """Return exact UTF-8 bytes sent for one bulk request."""
+ return len(
+ json_request_body(
+ self.batch_payload(
+ texts,
+ input_attributions=input_attributions,
+ input_metadata=input_metadata,
+ ),
+ include_orchestrator_session=True,
+ )
+ )
+
+ def batch_capabilities(self) -> dict[str, int]:
+ """Read enforced bulk request ceilings from contextual-orchestrator."""
+ headers = {"authorization": f"Bearer {self._api_key}"}
+ response = get_json(
+ f"{self._base_url}/batch/embeddings/capabilities",
+ headers=headers,
+ timeout=self._timeout,
+ service_peer_name="contextual-orchestrator",
+ )
+ required = (
+ "max_request_body_bytes",
+ "max_inputs",
+ "max_total_tokens",
+ "max_tokens_per_part",
+ "max_chars_per_part",
+ "poll_after_ms",
+ "job_retention_ms",
+ )
+ if any(type(response.get(key)) is not int or response[key] < 1 for key in required):
+ raise ValueError("embedding batch capabilities are incomplete")
+ model = response.get("model")
+ if isinstance(model, str) and model.strip():
+ self._bind_model(response)
+ return {key: int(response[key]) for key in required}
+
@property
def resolved_model(self) -> str | None:
"""Return the provider-neutral model identity selected upstream."""
@@ -186,62 +256,3 @@ def orchestrator_embedding_client(base_url: str, api_key: str):
if not (base_url and api_key):
return NullEmbeddingClient()
return ContextualOrchestratorEmbeddingClient(base_url, api_key)
-
-
-def cosine_similarity(a: list[float], b: list[float]) -> float:
- """Cosine similarity mapped from ``[-1, 1]`` into the ``[0, 1]`` channel range."""
- dot = sum(x * y for x, y in zip(a, b))
- norm_a = math.sqrt(sum(x * x for x in a))
- norm_b = math.sqrt(sum(y * y for y in b))
- if norm_a == 0.0 or norm_b == 0.0:
- return 0.0
- cosine = dot / (norm_a * norm_b)
- return (cosine + 1.0) / 2.0
-
-
-def chunked_max_similarity(
- client: EmbeddingClient,
- text_a: str,
- text_b: str,
- *,
- chunker=chunk_by_paragraph,
-) -> tuple[float, Chunk, Chunk]:
- """Chunk both documents, embed every chunk, and return the single
- highest-scoring chunk pair.
-
- Embedding a whole document as one vector dilutes a short relevant unit
- with everything else in the same document. Max-pooling over chunk-pair
- similarity instead asks the right question for lineage matching: "is
- there ANY unit in A that plausibly matches ANY unit in B?" -- the
- standard passage-retrieval strategy for exactly this "relevant content
- is buried in a longer document" shape (see module docstring in
- ``chunking.py`` for the per-unit-type grounding).
-
- Falls back to whole-text embedding (a single implicit chunk) for any
- document that chunks to zero or one pieces, so short records (this
- project's real dataset's ``title_field``, ~28 characters on average)
- behave exactly as they did before chunking existed -- one embedding
- call each, same as :meth:`EmbeddingClient.embed`.
- """
- raw_chunks_a = chunker(text_a)
- raw_chunks_b = chunker(text_b)
- # Fallback applies for zero OR one chunk, not just zero: a single chunk
- # still means "nothing to max-pool over," and the chunker's own single
- # chunk may be normalized (e.g. paragraph-stripped) rather than the
- # original text, which would silently break the documented "behaves
- # exactly as it did before chunking existed" whole-text-embedding contract.
- chunks_a = raw_chunks_a if len(raw_chunks_a) > 1 else [Chunk(text=text_a, unit_type="whole", index=0)]
- chunks_b = raw_chunks_b if len(raw_chunks_b) > 1 else [Chunk(text=text_b, unit_type="whole", index=0)]
-
- vectors_a = [(chunk, client.embed(chunk.text)) for chunk in chunks_a]
- vectors_b = [(chunk, client.embed(chunk.text)) for chunk in chunks_b]
-
- best_score = 0.0
- best_pair: tuple[Chunk, Chunk] = (chunks_a[0], chunks_b[0])
- for chunk_a, vector_a in vectors_a:
- for chunk_b, vector_b in vectors_b:
- score = cosine_similarity(vector_a, vector_b)
- if score > best_score:
- best_score = score
- best_pair = (chunk_a, chunk_b)
- return best_score, best_pair[0], best_pair[1]
diff --git a/lineageweave/http_client.py b/lineageweave/http_client.py
index d1791cd05..4c1cf92da 100644
--- a/lineageweave/http_client.py
+++ b/lineageweave/http_client.py
@@ -34,8 +34,24 @@ class HttpClientError(RuntimeError):
"""The remote endpoint failed, returned a non-success status, or invalid JSON."""
-def json_request_body(payload: dict) -> bytes:
- """Serialize the exact JSON body sent by :func:`post_json`."""
+class HttpAdmissionDeferred(HttpClientError):
+ """The orchestrator admitted no provider work and supplied an exact retry delay."""
+
+ def __init__(self, retry_after_seconds: int) -> None:
+ super().__init__("remote service has no viable agent yet")
+ self.retry_after_seconds = retry_after_seconds
+
+
+def json_request_body(
+ payload: dict,
+ *,
+ include_orchestrator_session: bool = False,
+) -> bytes:
+ """Serialize a JSON body with bounded post provenance when requested.
+
+ ``session_id`` is an orchestrator transport field, so callers that only
+ size or persist a provider-neutral payload retain their existing bytes.
+ """
request_payload = payload
request_metadata = current_llm_metadata()
if request_metadata:
@@ -47,6 +63,10 @@ def json_request_body(payload: dict) -> bytes:
request_payload["metadata"] = {**existing_metadata, **request_metadata}
else:
raise ValueError("metadata must be an object")
+ if include_orchestrator_session:
+ session_id = request_metadata.get("lineageweave_post_session_id")
+ if session_id:
+ request_payload["session_id"] = session_id
return json.dumps(request_payload).encode("utf-8")
@@ -151,6 +171,7 @@ def _request(
timeout: float,
maximum_response_bytes: int | None = None,
expected_response_media_type: str | None = None,
+ response_control_headers: dict[str, str] | None = None,
) -> tuple[int, bytes]:
"""Perform one bounded HTTP(S) request without exposing provider transport exception details."""
@@ -207,6 +228,10 @@ def _request(
response,
maximum_response_bytes=limit,
)
+ if response_control_headers is not None:
+ retry_after = response.getheader("Retry-After")
+ if retry_after is not None:
+ response_control_headers["retry-after"] = retry_after
return response.status, raw
except (OSError, ValueError, http.client.HTTPException) as exc:
# Chain internally for operator logging; the exposed
@@ -273,18 +298,49 @@ def post_json(
},
) as span:
inject_trace_context(request_headers)
+ response_control_headers: dict[str, str] = {}
status, raw = _request(
"POST",
url,
- body=json_request_body(payload),
+ body=json_request_body(
+ payload,
+ include_orchestrator_session=(
+ service_peer_name == "contextual-orchestrator"
+ ),
+ ),
headers=request_headers,
timeout=timeout,
+ response_control_headers=response_control_headers,
)
if span is not None:
span.set_attribute("http.response.status_code", status)
if status >= 400:
if span is not None:
span.set_attribute("error.type", str(status))
+ if status == 503:
+ try:
+ error_payload = _decode_json_object(raw, hostname).get("error")
+ except HttpClientError:
+ error_payload = None
+ if (
+ isinstance(error_payload, dict)
+ and error_payload.get("code") == "no_viable_agent"
+ ):
+ detail = error_payload.get("detail")
+ retry_after = response_control_headers.get("retry-after", "")
+ detail_seconds = (
+ detail.get("retry_after_seconds")
+ if isinstance(detail, dict)
+ else None
+ )
+ if (
+ retry_after.isascii()
+ and retry_after.isdigit()
+ and int(retry_after) > 0
+ and type(detail_seconds) is int
+ and detail_seconds == int(retry_after)
+ ):
+ raise HttpAdmissionDeferred(detail_seconds)
raise HttpClientError(f"HTTP {status} from {hostname}")
try:
return _decode_json_object(raw, hostname)
diff --git a/lineageweave/ontology.py b/lineageweave/ontology.py
index 5d0b3d508..20e0d7b95 100644
--- a/lineageweave/ontology.py
+++ b/lineageweave/ontology.py
@@ -19,6 +19,7 @@
from __future__ import annotations
from pathlib import Path
+from importlib.resources import files
from rdflib import Graph, Namespace
from rdflib.namespace import OWL, RDF, RDFS, SKOS
@@ -35,7 +36,9 @@
#: `common_lookup_value.lookup_code` string it corresponds to.
LOOKUP_CODE = LW.lookupCode
-_ONTOLOGY_PATH = Path(__file__).resolve().parents[1] / "docs" / "ontology" / "lineageweave-kg.ttl"
+_SOURCE_ONTOLOGY_PATH = (
+ Path(__file__).resolve().parents[1] / "docs" / "ontology" / "lineageweave-kg.ttl"
+)
def load_ontology() -> Graph:
@@ -46,7 +49,9 @@ def load_ontology() -> Graph:
on import-time caching.
"""
graph = Graph()
- graph.parse(_ONTOLOGY_PATH, format="turtle")
+ packaged = files("lineageweave").joinpath("data", "lineageweave-kg.ttl")
+ ontology_path = _SOURCE_ONTOLOGY_PATH if _SOURCE_ONTOLOGY_PATH.is_file() else packaged
+ graph.parse(ontology_path, format="turtle")
return graph
diff --git a/lineageweave/operations_case_analysis.py b/lineageweave/operations_case_analysis.py
index 96df33bf1..b5e7b9fb4 100644
--- a/lineageweave/operations_case_analysis.py
+++ b/lineageweave/operations_case_analysis.py
@@ -114,6 +114,7 @@ class OperationsEvidenceSource:
text: str
observed_at: datetime | None = None
time_axis_code: str | None = None
+ source_text: str | None = None
@property
def input_sha256(self) -> str:
@@ -419,7 +420,10 @@ def analyze(
"mode": "auto",
"reasoning_effort": "auto",
},
- headers={"authorization": f"Bearer {self._api_key}"},
+ headers={
+ "authorization": f"Bearer {self._api_key}",
+ "x-request-timeout-ms": str(round(self._timeout * 1000)),
+ },
timeout=self._timeout,
)
parsed = parse_operations_case_response(
diff --git a/lineageweave/product_semantics.py b/lineageweave/product_semantics.py
new file mode 100644
index 000000000..4951066d4
--- /dev/null
+++ b/lineageweave/product_semantics.py
@@ -0,0 +1,171 @@
+"""Evidence-bound product extraction and fail-closed catalog resolution."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import unicodedata
+from dataclasses import dataclass
+
+from .http_client import chat_completion_content, post_json
+
+
+@dataclass(frozen=True)
+class ProductEvidenceSource:
+ """One authorized source whose exact text may support a product mention."""
+
+ post_id: str
+ text: str
+
+ @property
+ def input_sha256(self) -> str:
+ """Return the digest binding derived evidence to this source text."""
+ return hashlib.sha256(self.text.encode("utf-8")).hexdigest()
+
+
+@dataclass(frozen=True)
+class ProductMention:
+ """One validated product span, not yet forced onto a catalog identity."""
+
+ extracted_product_name: str
+ evidence_text: str
+ evidence_post_id: str
+ evidence_input_sha256: str
+
+
+@dataclass(frozen=True)
+class ResolvedProductMention:
+ """A mention with a unique, missing, or tied catalog outcome."""
+
+ mention: ProductMention
+ resolution_status_code: str
+ product_catalog_id: str | None
+
+
+def normalize_product_alias(value: str) -> str:
+ """Normalize catalog lookup text without deriving identity from keywords."""
+ return " ".join(unicodedata.normalize("NFKC", value).casefold().split())
+
+
+def product_analysis_input_sha256(
+ sources: tuple[ProductEvidenceSource, ...],
+) -> str:
+ """Digest the exact ordered authorized source window used for extraction."""
+ encoded = json.dumps(
+ [(source.post_id, source.input_sha256) for source in sources],
+ separators=(",", ":"),
+ ensure_ascii=False,
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def parse_product_mentions(
+ content: str, sources: tuple[ProductEvidenceSource, ...]
+) -> tuple[ProductMention, ...] | None:
+ """Validate structured output against exact authorized source spans."""
+ source_by_id = {source.post_id: source for source in sources}
+ try:
+ payload = json.loads(content)
+ except json.JSONDecodeError:
+ return None
+ if not isinstance(payload, list):
+ return None
+ mentions: list[ProductMention] = []
+ seen: set[tuple[str, str, str]] = set()
+ for item in payload:
+ if not isinstance(item, dict):
+ return None
+ name = item.get("product_name")
+ evidence = item.get("evidence_text")
+ post_id = item.get("evidence_post_id")
+ source = source_by_id.get(post_id)
+ if (
+ not isinstance(name, str)
+ or not name.strip()
+ or not isinstance(evidence, str)
+ or not evidence.strip()
+ or source is None
+ or evidence not in source.text
+ ):
+ return None
+ key = (normalize_product_alias(name), evidence, post_id)
+ if key in seen:
+ return None
+ seen.add(key)
+ mentions.append(ProductMention(name.strip(), evidence, post_id, source.input_sha256))
+ return tuple(mentions)
+
+
+def resolve_product_mention(
+ mention: ProductMention, catalog_matches: tuple[str, ...] | None
+) -> ResolvedProductMention:
+ """Bind only one exact normalized catalog match; preserve misses and ties."""
+ if catalog_matches is None:
+ return ResolvedProductMention(mention, "unavailable", None)
+ distinct = tuple(dict.fromkeys(catalog_matches))
+ if len(distinct) == 1:
+ return ResolvedProductMention(mention, "unique", distinct[0])
+ return ResolvedProductMention(
+ mention, "missing" if not distinct else "tie", None
+ )
+
+
+_PROMPT = """Extract product entities from the authorized sources semantically.
+Do not classify by keywords or tags and do not invent a product. Return ONLY a
+JSON array. Each object has product_name, evidence_post_id, and evidence_text.
+evidence_text must be a verbatim span that identifies the product in that same
+source. Return [] when no source span supports a product entity.
+
+Authorized sources:
+{sources}
+"""
+
+
+class ContextualOrchestratorProductExtractionClient:
+ """Extract cited product mentions through the provider-neutral gateway."""
+
+ available = True
+
+ def __init__(self, base_url: str, api_key: str, *, timeout: float = 180.0) -> None:
+ self._base_url = base_url.rstrip("/")
+ self._api_key = api_key
+ self._timeout = timeout
+
+ def extract(
+ self, sources: tuple[ProductEvidenceSource, ...]
+ ) -> tuple[ProductMention, ...]:
+ """Return only fully validated, source-bound product mentions."""
+ response = post_json(
+ f"{self._base_url}/v1/chat/completions",
+ {
+ "model": "orchestrator/auto",
+ "messages": [
+ {
+ "role": "user",
+ "content": _PROMPT.format(
+ sources="\n\n".join(
+ f"post_id={source.post_id}\n{source.text}"
+ for source in sources
+ )
+ ),
+ }
+ ],
+ "mode": "auto",
+ "reasoning_effort": "auto",
+ },
+ timeout=self._timeout,
+ headers={
+ "authorization": f"Bearer {self._api_key}",
+ "x-request-timeout-ms": str(round(self._timeout * 1000)),
+ },
+ )
+ try:
+ content = chat_completion_content(response)
+ except TypeError as exc:
+ raise RuntimeError(
+ "contextual-orchestrator returned invalid product evidence"
+ ) from exc
+ parsed = parse_product_mentions(content, sources)
+ if parsed is None:
+ raise RuntimeError("contextual-orchestrator returned invalid product evidence")
+ return parsed
diff --git a/migrations/0035_body_search_prefix.sql b/migrations/0035_body_search_prefix.sql
index cc0114ec3..13f1db869 100644
--- a/migrations/0035_body_search_prefix.sql
+++ b/migrations/0035_body_search_prefix.sql
@@ -1,13 +1,5 @@
--- Keep body search indexed without duplicating the full, potentially very large
--- source body. The detail endpoint still returns the complete post_body.
+-- Historical boundary retained for sorted replay. Migration 0036 supersedes
+-- both original body indexes with image-safe normalized search indexes, so
+-- recreating the obsolete indexes here would make every replay build and then
+-- immediately drop two corpus-wide GIN indexes.
create extension if not exists pg_trgm;
-
-create index concurrently if not exists source_post_body_prefix_trgm_idx
- on source_post using gin (
- lower(left(coalesce(post_body, ''), 16384)) gin_trgm_ops
- );
-
-create index concurrently if not exists source_post_body_fts_idx
- on source_post using gin (
- to_tsvector('simple', coalesce(post_body, ''))
- );
diff --git a/migrations/0228_product_semantic_catalog.sql b/migrations/0228_product_semantic_catalog.sql
new file mode 100644
index 000000000..0e9a83aa1
--- /dev/null
+++ b/migrations/0228_product_semantic_catalog.sql
@@ -0,0 +1,89 @@
+-- ADR 0228: evidence-bound product identity and operational relationships.
+create table if not exists product_catalog (
+ product_catalog_id uuid primary key default gen_random_uuid(),
+ canonical_product_name text not null check (btrim(canonical_product_name) <> ''),
+ product_level_code text not null
+ check (product_level_code in ('product_group', 'product_model', 'variant', 'trade_item')),
+ parent_product_catalog_id uuid references product_catalog(product_catalog_id),
+ product_catalog_code text,
+ created_at timestamptz not null default now(),
+ unique (product_catalog_code)
+);
+
+create table if not exists product_catalog_identifier (
+ product_catalog_id uuid not null references product_catalog(product_catalog_id),
+ identifier_scheme_code text not null check (identifier_scheme_code in ('gtin', 'mpn')),
+ identifier_value text not null check (btrim(identifier_value) <> ''),
+ issuer_scope_text text not null check (btrim(issuer_scope_text) <> ''),
+ primary key (identifier_scheme_code, identifier_value, issuer_scope_text),
+ unique (product_catalog_id, identifier_scheme_code, identifier_value, issuer_scope_text)
+);
+
+create table if not exists product_catalog_alias (
+ product_catalog_id uuid not null references product_catalog(product_catalog_id),
+ normalized_alias_text text not null check (btrim(normalized_alias_text) <> ''),
+ alias_text text not null check (btrim(alias_text) <> ''),
+ primary key (product_catalog_id, normalized_alias_text)
+);
+create index if not exists product_catalog_alias_lookup_idx
+ on product_catalog_alias (normalized_alias_text, product_catalog_id);
+
+create table if not exists post_product_analysis (
+ post_id uuid primary key references source_post(post_id) on delete cascade,
+ source_body_sha256 text not null check (source_body_sha256 ~ '^[0-9a-f]{64}$'),
+ analysis_input_sha256 text not null check (analysis_input_sha256 ~ '^[0-9a-f]{64}$'),
+ orchestrator_session_id text not null check (btrim(orchestrator_session_id) <> ''),
+ analyzed_at timestamptz not null default now()
+);
+
+create table if not exists post_product_mention (
+ post_id uuid not null references post_product_analysis(post_id) on delete cascade,
+ mention_ordinal integer not null check (mention_ordinal >= 0),
+ product_catalog_id uuid references product_catalog(product_catalog_id),
+ extracted_product_name text not null check (btrim(extracted_product_name) <> ''),
+ resolution_status_code text not null
+ check (resolution_status_code in ('unique', 'missing', 'tie', 'unavailable')),
+ evidence_text text not null check (btrim(evidence_text) <> ''),
+ evidence_post_id uuid not null references source_post(post_id),
+ evidence_input_sha256 text not null
+ check (evidence_input_sha256 ~ '^[0-9a-f]{64}$'),
+ primary key (post_id, mention_ordinal),
+ check ((resolution_status_code = 'unique') = (product_catalog_id is not null))
+);
+create index if not exists post_product_mention_catalog_idx
+ on post_product_mention (product_catalog_id, post_id)
+ where product_catalog_id is not null;
+
+create table if not exists product_operations_fact_relation (
+ post_id uuid not null,
+ mention_ordinal integer not null,
+ case_kind_code text not null,
+ fact_ordinal integer not null,
+ relation_type_code text not null
+ check (relation_type_code in ('concerns_product', 'changes_product', 'originates_from_product', 'senses_product')),
+ evidence_text text not null check (btrim(evidence_text) <> ''),
+ evidence_post_id uuid not null references source_post(post_id),
+ evidence_input_sha256 text not null
+ check (evidence_input_sha256 ~ '^[0-9a-f]{64}$'),
+ primary key (post_id, mention_ordinal, case_kind_code, fact_ordinal, relation_type_code),
+ foreign key (post_id, mention_ordinal)
+ references post_product_mention(post_id, mention_ordinal) on delete cascade,
+ foreign key (post_id, case_kind_code, fact_ordinal)
+ references operations_case_fact(post_id, case_kind_code, fact_ordinal) on delete cascade
+);
+
+create table if not exists product_project_relation (
+ post_id uuid not null,
+ mention_ordinal integer not null,
+ project_key text not null,
+ relation_type_code text not null check (relation_type_code = 'used_by_project'),
+ evidence_text text not null check (btrim(evidence_text) <> ''),
+ evidence_post_id uuid not null references source_post(post_id),
+ evidence_input_sha256 text not null
+ check (evidence_input_sha256 ~ '^[0-9a-f]{64}$'),
+ primary key (post_id, mention_ordinal, project_key),
+ foreign key (post_id, mention_ordinal)
+ references post_product_mention(post_id, mention_ordinal) on delete cascade,
+ foreign key (post_id, project_key)
+ references post_project_mention(post_id, project_key) on delete cascade
+);
diff --git a/migrations/0229_post_content_admission_deferral.sql b/migrations/0229_post_content_admission_deferral.sql
new file mode 100644
index 000000000..4505b182c
--- /dev/null
+++ b/migrations/0229_post_content_admission_deferral.sql
@@ -0,0 +1,7 @@
+-- ADR 0098 amendment: provider admission deferral is durable queue timing,
+-- not a consumed provider attempt.
+alter table post_content_ingestion_job
+ add column if not exists next_attempt_at timestamptz;
+
+create index if not exists post_content_ingestion_next_attempt_idx
+ on post_content_ingestion_job (status_code, next_attempt_at, queued_at);
diff --git a/migrations/0230_voice_semantic_taxonomy.sql b/migrations/0230_voice_semantic_taxonomy.sql
new file mode 100644
index 000000000..8a1ff4ef0
--- /dev/null
+++ b/migrations/0230_voice_semantic_taxonomy.sql
@@ -0,0 +1,200 @@
+-- ADR 0230: source-preserving, multi-membership voice taxonomy assertions.
+create table if not exists post_voice_classification_assertion (
+ classification_assertion_id uuid primary key default gen_random_uuid(),
+ post_id uuid not null references source_post(post_id) on delete cascade,
+ voice_concept_code text not null
+ check (voice_concept_code in ('voc', 'vocc', 'voco', 'vom', 'vop')),
+ assertion_status_code text not null
+ check (assertion_status_code in ('source', 'derived')),
+ evidence_span_start integer,
+ evidence_span_end integer,
+ evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'),
+ source_revision_digest text not null
+ check (source_revision_digest ~ '^[0-9a-f]{64}$'),
+ orchestrator_model_receipt text,
+ valid_from timestamptz,
+ valid_to timestamptz,
+ recorded_at timestamptz not null default now(),
+ supersedes_assertion_id uuid references post_voice_classification_assertion(classification_assertion_id),
+ check ((evidence_span_start is null) = (evidence_span_end is null)),
+ check (evidence_span_start is null or (evidence_span_start >= 0 and evidence_span_end > evidence_span_start)),
+ check (valid_to is null or valid_from is null or valid_to >= valid_from)
+);
+do $migration$
+begin
+ if not exists (
+ select 1 from pg_constraint
+ where conrelid = 'post_voice_classification_assertion'::regclass
+ and conname = 'post_voice_derived_receipt_check'
+ ) then
+ alter table post_voice_classification_assertion
+ add constraint post_voice_derived_receipt_check check (
+ assertion_status_code = 'source'
+ or (
+ evidence_span_start is not null
+ and orchestrator_model_receipt is not null
+ and btrim(orchestrator_model_receipt) <> ''
+ )
+ );
+ end if;
+end
+$migration$;
+create index if not exists post_voice_assertion_scope_idx
+ on post_voice_classification_assertion (post_id, valid_from, voice_concept_code);
+drop index if exists post_voice_assertion_idempotency_idx;
+with ranked_open_assertion as (
+ select classification_assertion_id,
+ row_number() over (
+ partition by post_id, assertion_status_code, voice_concept_code
+ order by recorded_at desc, classification_assertion_id desc
+ ) as duplicate_rank
+ from post_voice_classification_assertion
+ where valid_to is null
+)
+update post_voice_classification_assertion assertion
+ set valid_to = greatest(current_timestamp, assertion.valid_from)
+ from ranked_open_assertion ranked
+ where assertion.classification_assertion_id = ranked.classification_assertion_id
+ and ranked.duplicate_rank > 1;
+create unique index if not exists post_voice_assertion_open_scope_idx
+ on post_voice_classification_assertion
+ (post_id, assertion_status_code, voice_concept_code)
+ where valid_to is null;
+
+insert into post_voice_classification_assertion (
+ post_id, voice_concept_code, assertion_status_code,
+ evidence_sha256, source_revision_digest
+)
+select post.post_id,
+ lower(post.voc_type_code),
+ 'source',
+ encode(sha256(convert_to(post.voc_type_code, 'UTF8')), 'hex'),
+ encode(sha256(convert_to(coalesce(post.post_body, ''), 'UTF8')), 'hex')
+ from source_post post
+ where lower(post.voc_type_code) in ('voc', 'vocc', 'voco', 'vom', 'vop')
+on conflict (post_id, assertion_status_code, voice_concept_code)
+where valid_to is null
+do nothing;
+
+-- Source labels are recorded provenance, not future business-event claims.
+-- Repair rows written by an earlier replay of this migration without changing
+-- a separately sourced assertion that happens to share the post and concept.
+update post_voice_classification_assertion assertion
+ set valid_from = null
+ from source_post post
+ where assertion.post_id = post.post_id
+ and assertion.assertion_status_code = 'source'
+ and assertion.voice_concept_code = lower(post.voc_type_code)
+ and assertion.evidence_sha256 =
+ encode(sha256(convert_to(post.voc_type_code, 'UTF8')), 'hex')
+ and assertion.source_revision_digest =
+ encode(sha256(convert_to(coalesce(post.post_body, ''), 'UTF8')), 'hex')
+ and assertion.valid_from is not null;
+
+create or replace function reconcile_post_voice_source_assertion()
+returns trigger
+language plpgsql
+as $function$
+declare
+ current_evidence_sha256 text;
+ current_revision_digest text;
+ matching_assertion_id uuid;
+ prior_assertion_id uuid;
+begin
+ if lower(coalesce(new.voc_type_code, '')) not in
+ ('voc', 'vocc', 'voco', 'vom', 'vop') then
+ update post_voice_classification_assertion
+ set valid_to = current_timestamp
+ where post_id = new.post_id
+ and assertion_status_code = 'source'
+ and voice_concept_code = lower(coalesce(old.voc_type_code, new.voc_type_code))
+ and valid_to is null;
+ return new;
+ end if;
+
+ current_evidence_sha256 :=
+ encode(sha256(convert_to(new.voc_type_code, 'UTF8')), 'hex');
+ current_revision_digest :=
+ encode(sha256(convert_to(coalesce(new.post_body, ''), 'UTF8')), 'hex');
+
+ select classification_assertion_id
+ into matching_assertion_id
+ from post_voice_classification_assertion
+ where post_id = new.post_id
+ and assertion_status_code = 'source'
+ and voice_concept_code = lower(new.voc_type_code)
+ and evidence_sha256 = current_evidence_sha256
+ and source_revision_digest = current_revision_digest
+ and valid_to is null
+ order by recorded_at desc, classification_assertion_id
+ limit 1;
+
+ if matching_assertion_id is not null then
+ update post_voice_classification_assertion
+ set valid_to = current_timestamp
+ where post_id = new.post_id
+ and assertion_status_code = 'source'
+ and voice_concept_code = lower(coalesce(old.voc_type_code, new.voc_type_code))
+ and valid_to is null
+ and classification_assertion_id <> matching_assertion_id;
+ return new;
+ end if;
+
+ select classification_assertion_id
+ into prior_assertion_id
+ from post_voice_classification_assertion
+ where post_id = new.post_id
+ and assertion_status_code = 'source'
+ and voice_concept_code = lower(coalesce(old.voc_type_code, new.voc_type_code))
+ and valid_to is null
+ order by recorded_at desc, classification_assertion_id
+ limit 1;
+
+ update post_voice_classification_assertion
+ set valid_to = current_timestamp
+ where post_id = new.post_id
+ and assertion_status_code = 'source'
+ and voice_concept_code = lower(coalesce(old.voc_type_code, new.voc_type_code))
+ and valid_to is null;
+
+ insert into post_voice_classification_assertion (
+ post_id, voice_concept_code, assertion_status_code,
+ evidence_sha256, source_revision_digest, supersedes_assertion_id
+ ) values (
+ new.post_id, lower(new.voc_type_code), 'source',
+ current_evidence_sha256, current_revision_digest, prior_assertion_id
+ )
+ on conflict (
+ post_id, assertion_status_code, voice_concept_code
+ ) where valid_to is null do nothing;
+ return new;
+end
+$function$;
+
+drop trigger if exists source_post_voice_assertion_reconcile on source_post;
+create trigger source_post_voice_assertion_reconcile
+after insert or update of voc_type_code, post_body on source_post
+for each row execute function reconcile_post_voice_source_assertion();
+
+create table if not exists organization_voice_relationship_assertion (
+ relationship_assertion_id uuid primary key default gen_random_uuid(),
+ post_id uuid not null references source_post(post_id) on delete cascade,
+ corporate_entity_id uuid not null references corporate_entity(corporate_entity_id),
+ relationship_concept_code text not null
+ check (relationship_concept_code in ('rel_voc', 'rel_vocc', 'rel_voco', 'rel_vom', 'rel_vop', 'rel_vos')),
+ evidence_span_start integer not null check (evidence_span_start >= 0),
+ evidence_span_end integer not null check (evidence_span_end > evidence_span_start),
+ evidence_sha256 text not null check (evidence_sha256 ~ '^[0-9a-f]{64}$'),
+ source_revision_digest text not null
+ check (source_revision_digest ~ '^[0-9a-f]{64}$'),
+ orchestrator_model_receipt text not null check (btrim(orchestrator_model_receipt) <> ''),
+ product_catalog_id uuid references product_catalog(product_catalog_id),
+ valid_from timestamptz,
+ valid_to timestamptz,
+ recorded_at timestamptz not null default now(),
+ supersedes_assertion_id uuid references organization_voice_relationship_assertion(relationship_assertion_id),
+ check (valid_to is null or valid_from is null or valid_to >= valid_from)
+);
+create index if not exists organization_voice_assertion_scope_idx
+ on organization_voice_relationship_assertion
+ (corporate_entity_id, valid_from, relationship_concept_code, post_id);
diff --git a/pyproject.toml b/pyproject.toml
index f2e8bb1ef..e7a736f0c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -60,6 +60,9 @@ backend = [
[tool.setuptools.packages.find]
include = ["lineageweave*", "backend*"]
+[tool.setuptools.package-data]
+lineageweave = ["data/*.ttl"]
+
[tool.pytest.ini_options]
testpaths = ["tests", "backend/tests"]
pythonpath = ["."]
diff --git a/scripts/backfill_post_embeddings.py b/scripts/backfill_post_embeddings.py
index 48a36bad8..03490e913 100755
--- a/scripts/backfill_post_embeddings.py
+++ b/scripts/backfill_post_embeddings.py
@@ -22,7 +22,6 @@
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--limit", required=True, type=int)
parser.add_argument(
"--target-dsn",
default=os.environ.get(
@@ -33,17 +32,25 @@ def _parser() -> argparse.ArgumentParser:
return parser
-async def _run(target_dsn: str, input_limit: int) -> dict[str, int | str]:
+async def _run(target_dsn: str) -> dict[str, int | str]:
client = orchestrator_embedding_client(
os.environ.get("ORCHESTRATOR_BASE_URL", ""),
os.environ.get("ORCHESTRATOR_API_KEY", ""),
)
if not client.available:
raise RuntimeError("embedding is unavailable; configure contextual-orchestrator")
+ capabilities = client.batch_capabilities()
+ # LineageWeave bounds only the provider-neutral HTTP envelope. The
+ # advertised token/character ceilings are enforced by the orchestrator's
+ # Rust token-boundary splitter and durable shard runner; reproducing that
+ # arithmetic here would create a divergent model/provider policy boundary.
conn = await asyncpg.connect(target_dsn)
try:
return await backfill_post_content_embeddings(
- conn, client, input_limit=input_limit
+ conn,
+ client,
+ max_request_body_bytes=capabilities["max_request_body_bytes"],
+ max_inputs=capabilities["max_inputs"],
)
finally:
await conn.close()
@@ -52,9 +59,7 @@ async def _run(target_dsn: str, input_limit: int) -> dict[str, int | str]:
def main() -> None:
"""Run one operator-bounded embedding batch and print aggregate counts only."""
args = _parser().parse_args()
- if args.limit < 1:
- raise SystemExit("--limit must be positive")
- print(json.dumps(asyncio.run(_run(args.target_dsn, args.limit)), sort_keys=True))
+ print(json.dumps(asyncio.run(_run(args.target_dsn)), sort_keys=True))
if __name__ == "__main__":
diff --git a/tests/test_backend_worker_process.py b/tests/test_backend_worker_process.py
new file mode 100644
index 000000000..a03e24c61
--- /dev/null
+++ b/tests/test_backend_worker_process.py
@@ -0,0 +1,97 @@
+"""Process-ownership tests for API and durable queue consumers."""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+
+from backend.app import main, worker
+
+
+class _Closable:
+ def __init__(self) -> None:
+ self.closed = False
+
+ async def close(self) -> None:
+ self.closed = True
+
+ async def aclose(self) -> None:
+ self.closed = True
+
+
+def test_api_lifespan_opens_clients_without_starting_queue_workers(monkeypatch) -> None:
+ """Serving HTTP never competes with the dedicated durable worker service."""
+ pool = _Closable()
+ valkey = _Closable()
+ monkeypatch.setattr(
+ main,
+ "load_settings",
+ lambda: SimpleNamespace(database_url="db", valkey_url="valkey"),
+ )
+ monkeypatch.setattr(main, "create_pool", lambda _url: _async_value(pool))
+ monkeypatch.setattr(main, "create_valkey_client", lambda _url: valkey)
+ monkeypatch.setattr(main, "configure_telemetry", lambda _name: None)
+ monkeypatch.setattr(main, "shutdown_telemetry", lambda: None)
+ app = SimpleNamespace(state=SimpleNamespace())
+
+ async def exercise() -> None:
+ async with main.lifespan(app):
+ assert app.state.pool is pool
+ assert app.state.valkey is valkey
+ assert not hasattr(app.state, "post_content_worker")
+ assert not hasattr(app.state, "analysis_run_worker")
+ assert not hasattr(app.state, "global_ask_worker")
+
+ asyncio.run(exercise())
+ assert pool.closed
+ assert valkey.closed
+
+
+def test_worker_process_owns_all_three_durable_consumers(monkeypatch) -> None:
+ """Analysis, post-content, and Global Ask queues share one worker owner."""
+ pool = _Closable()
+ valkey = _Closable()
+ calls: list[str] = []
+ settings = SimpleNamespace(
+ database_url="db",
+ valkey_url="valkey",
+ tepp_transport_url="",
+ tepp_api_key="",
+ orchestrator_answer_timeout_seconds=570.0,
+ )
+
+ async def called(name: str, *_args, **_kwargs) -> None:
+ calls.append(name)
+
+ monkeypatch.setattr(worker, "load_settings", lambda: settings)
+ monkeypatch.setattr(worker, "create_pool", lambda _url: _async_value(pool))
+ monkeypatch.setattr(worker, "create_valkey_client", lambda _url: valkey)
+ monkeypatch.setattr(worker, "configure_telemetry", lambda _name: None)
+ monkeypatch.setattr(worker, "shutdown_telemetry", lambda: calls.append("shutdown"))
+ monkeypatch.setattr(worker, "configured_tepp_client", lambda *_args: object())
+ monkeypatch.setattr(worker, "_adjudication_client", lambda: object())
+ monkeypatch.setattr(worker, "_vision_client", lambda: object())
+ monkeypatch.setattr(worker, "_embedding_client", lambda: object())
+ monkeypatch.setattr(worker, "_post_structure_client", lambda: object())
+ monkeypatch.setattr(worker, "_post_chat_client", lambda **_kwargs: object())
+ monkeypatch.setattr(
+ worker, "run_analysis_run_worker", lambda *a, **kw: called("analysis", *a, **kw)
+ )
+ monkeypatch.setattr(
+ worker, "run_post_content_worker", lambda *a, **kw: called("content", *a, **kw)
+ )
+ monkeypatch.setattr(
+ worker, "run_global_ask_worker", lambda *a, **kw: called("global_ask", *a, **kw)
+ )
+
+ asyncio.run(worker.run_worker_process())
+
+ assert calls[:3] == ["analysis", "content", "global_ask"]
+ assert calls[-1] == "shutdown"
+ assert pool.closed
+ assert valkey.closed
+
+
+async def _async_value(value):
+ """Return one test double through an awaitable seam."""
+ return value
diff --git a/tests/test_contextual_orchestrator_start.py b/tests/test_contextual_orchestrator_start.py
index 4830dc4c6..11004d29c 100644
--- a/tests/test_contextual_orchestrator_start.py
+++ b/tests/test_contextual_orchestrator_start.py
@@ -65,10 +65,7 @@ def test_provider_key_is_not_aliased_as_gateway_transport(monkeypatch) -> None:
module.main()
-@pytest.mark.parametrize("embedding_model", ["embedding-model", ""])
-def test_bootstrap_registers_configured_remote_embedding_agent(
- monkeypatch, embedding_model: str
-) -> None:
+def test_bootstrap_delegates_embedding_discovery_upstream(monkeypatch) -> None:
module = _load_start_module()
captured: dict[str, object] = {}
@@ -114,10 +111,7 @@ def serve() -> None:
monkeypatch.setenv("BYTEZ_API_KEY", "bytez-key")
monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_TOKEN", "orchestrator-token")
monkeypatch.setenv("LLM_GATEWAY_API_URL", "https://gateway.example")
- if embedding_model:
- monkeypatch.setenv("LLM_GATEWAY_EMBEDDING_MODEL", embedding_model)
- else:
- monkeypatch.delenv("LLM_GATEWAY_EMBEDDING_MODEL", raising=False)
+ monkeypatch.setenv("BATCH_JOB_REGISTRY_VALKEY_URL", "redis://valkey:6379/1")
module.main()
@@ -127,6 +121,7 @@ def serve() -> None:
assert "--embedding-model" not in argv
assert captured["credentials"] == [
("LLM_GATEWAY_API_KEY", "provider-key"),
+ ("batch_job_registry_valkey_url", "redis://valkey:6379/1"),
("OPENAI_API_KEY", "openai-key"),
("OPENROUTER_API_KEY", "openrouter-key"),
("NVIDIA_NIM_API_KEY", "nim-key"),
@@ -141,24 +136,9 @@ def serve() -> None:
"NVIDIA_NIM_API_KEY",
"NVIDIA_NIM_API_KEY_SUB",
"BYTEZ_API_KEY",
+ "BATCH_JOB_REGISTRY_VALKEY_URL",
} & os.environ.keys()
agents = captured["agents"]
assert isinstance(agents, dict)
- embedding_agents = [
- agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])
- ]
- if embedding_model:
- assert embedding_agents == [
- {
- "id": "gateway_embedding_agent",
- "model": embedding_model,
- "provider_protocol": "auto",
- "base_url": "https://gateway.example/v1",
- "credential_key": "LLM_GATEWAY_API_KEY",
- "tags": ["embedding"],
- "priority": 1,
- }
- ]
- else:
- assert embedding_agents == []
- assert "LLM_GATEWAY_EMBEDDING_MODEL" not in os.environ
+ assert not [agent for agent in agents["agents"] if "embedding" in agent.get("tags", [])]
+ assert "--auto-discover-model-agents" in argv
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index 314f21bf5..f3073cdbc 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "d9c62be9feea24fdaeb8453f3c72f2c2b0237143"
+ expected_embedding_contract_commit = "071c8f84e10fbd591d3915b1d5e932223cd1c640"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
@@ -134,10 +134,12 @@ def test_orchestrator_runtime_pin_matches_adr() -> None:
def test_embedding_bootstrap_contract_keeps_request_model_free() -> None:
- """ADR distinguishes remote-agent registration from request selection."""
+ """ADR assigns embedding discovery and selection to the orchestrator."""
adr = (_ADR_DIRECTORY / "0030-external-llm-gateway-environment.md").read_text(
encoding="utf-8"
)
- assert "LineageWeave embedding requests do not select a model" in adr
- assert "LLM_GATEWAY_EMBEDDING_MODEL" in adr
- assert "application code never reads it" in adr
+ assert "does not configure an embedding model" in adr
+ assert "discovered provider catalog" in adr
+ assert "LLM_GATEWAY_EMBEDDING_MODEL" not in (_ROOT / ".env.example").read_text(
+ encoding="utf-8"
+ )
diff --git a/tests/test_embedding_backfill.py b/tests/test_embedding_backfill.py
index 01f574e91..451f2670d 100644
--- a/tests/test_embedding_backfill.py
+++ b/tests/test_embedding_backfill.py
@@ -7,7 +7,10 @@
import pytest
-from lineageweave.embedding_backfill import backfill_post_content_embeddings
+from lineageweave.embedding_backfill import (
+ _SELECT_UNITS_SQL,
+ backfill_post_content_embeddings,
+)
class _Transaction:
@@ -34,12 +37,14 @@ def __init__(self, rows):
async def fetch(self, query, *args):
if "from post_content_unit unit" in query:
return self.rows
+ selected_unit_ids = set(args[1])
return [
{
"post_content_unit_id": unit_id,
"post_content_embedding_id": embedding_id,
}
for unit_id, embedding_id in self.embedding_ids.items()
+ if unit_id in selected_unit_ids
]
def transaction(self):
@@ -67,6 +72,9 @@ def embed_many(self, texts, **kwargs):
self.resolved_model = "synthetic-embedding-model"
return [[float(index), 1.0] for index, _text in enumerate(texts)]
+ def batch_request_body_size(self, texts, **kwargs):
+ return sum(len(text.encode("utf-8")) for text in texts) + 100 * len(texts)
+
def _row(index: int) -> dict[str, object]:
return {
@@ -90,7 +98,9 @@ def test_bulk_backfill_calls_provider_once_and_persists_in_one_transaction() ->
conn = _Connection(rows)
client = _EmbeddingClient()
- result = asyncio.run(backfill_post_content_embeddings(conn, client, input_limit=2))
+ result = asyncio.run(
+ backfill_post_content_embeddings(conn, client, max_request_body_bytes=10_000, max_inputs=2048)
+ )
assert result == {
"selected_units": 2,
@@ -115,7 +125,9 @@ def test_provider_failure_makes_no_database_change() -> None:
client = _EmbeddingClient(fail=True)
with pytest.raises(RuntimeError, match="synthetic provider failure"):
- asyncio.run(backfill_post_content_embeddings(conn, client, input_limit=2))
+ asyncio.run(
+ backfill_post_content_embeddings(conn, client, max_request_body_bytes=10_000, max_inputs=2048)
+ )
assert conn.transaction_entries == 0
assert conn.executemany_calls == []
@@ -126,7 +138,9 @@ def test_empty_selection_skips_provider_and_transaction() -> None:
conn = _Connection([])
client = _EmbeddingClient()
- result = asyncio.run(backfill_post_content_embeddings(conn, client, input_limit=1))
+ result = asyncio.run(
+ backfill_post_content_embeddings(conn, client, max_request_body_bytes=10_000, max_inputs=2048)
+ )
assert result == {
"selected_units": 0,
@@ -135,3 +149,51 @@ def test_empty_selection_skips_provider_and_transaction() -> None:
}
assert client.calls == []
assert conn.transaction_entries == 0
+
+
+def test_oversized_first_unit_reaches_the_explicit_failure_guard() -> None:
+ """The SQL cannot hide a blocking unit and silently stall later work."""
+ row = _row(0)
+ row["unit_text"] = "x" * 200
+
+ with pytest.raises(
+ ValueError,
+ match="one semantic unit exceeds the advertised embedding request ceiling",
+ ):
+ asyncio.run(
+ backfill_post_content_embeddings(
+ _Connection([row]),
+ _EmbeddingClient(),
+ max_request_body_bytes=100,
+ max_inputs=2048,
+ )
+ )
+
+ assert "candidate_ordinal <= $2" in _SELECT_UNITS_SQL
+
+
+def test_bulk_backfill_packs_largest_prefix_within_advertised_body_ceiling() -> None:
+ rows = [_row(0), _row(1), _row(2)]
+ conn = _Connection(rows)
+ client = _EmbeddingClient()
+ two_input_size = client.batch_request_body_size(
+ [str(rows[0]["unit_text"]), str(rows[1]["unit_text"])]
+ )
+
+ result = asyncio.run(
+ backfill_post_content_embeddings(
+ conn, client, max_request_body_bytes=two_input_size, max_inputs=2048
+ )
+ )
+
+ assert result["selected_units"] == 2
+ assert len(client.calls[0][0]) == 2
+
+
+def test_candidate_window_is_bounded_before_window_functions() -> None:
+ """Each batch ranks at most the operator-advertised input ceiling."""
+ bounded_start = _SELECT_UNITS_SQL.index("bounded_candidates as materialized")
+ limit_position = _SELECT_UNITS_SQL.index("limit $2")
+ window_position = _SELECT_UNITS_SQL.index("row_number() over")
+
+ assert bounded_start < limit_position < window_position
diff --git a/tests/test_embedding_client.py b/tests/test_embedding_client.py
index a604e5330..833979964 100644
--- a/tests/test_embedding_client.py
+++ b/tests/test_embedding_client.py
@@ -1,92 +1,8 @@
-"""Unit tests for embedding_client.chunked_max_similarity's whole-text
-fallback contract, using a fake (non-real-provider) client -- no network,
-no credentials needed. The real-provider test in
-tests/test_real_provider_integration.py proves the same function works
-against a live embedding endpoint; this file proves the fallback logic
-itself is correct regardless of provider.
-"""
+"""Unit tests for the contextual-orchestrator embedding transport."""
from __future__ import annotations
-from lineageweave.chunking import Chunk
-from lineageweave.embedding_client import (
- ContextualOrchestratorEmbeddingClient,
- chunked_max_similarity,
-)
-
-
-class _RecordingFakeEmbeddingClient:
- """Deterministic fake: embeds a string as a length-1 vector of its own
- length, so equal-length strings score identically and call counts are
- trivially inspectable.
- """
-
- available = True
-
- def __init__(self) -> None:
- self.embed_calls: list[str] = []
-
- def embed(self, text: str) -> list[float]:
- self.embed_calls.append(text)
- return [float(len(text))]
-
-
-def _chunk_to_two_pieces(text: str) -> list[Chunk]:
- half = len(text) // 2
- return [
- Chunk(text=text[:half], unit_type="paragraph", index=0),
- Chunk(text=text[half:], unit_type="paragraph", index=1),
- ]
-
-
-def _chunk_to_one_piece(text: str) -> list[Chunk]:
- # Deliberately NOT the identical string -- a real chunker normalizes
- # (e.g. strips/collapses whitespace), which is exactly the case the
- # fallback must override so the original text still gets embedded.
- return [Chunk(text=text.strip(), unit_type="paragraph", index=0)]
-
-
-def _chunk_to_zero_pieces(text: str) -> list[Chunk]:
- return []
-
-
-def test_falls_back_to_whole_text_when_chunker_returns_zero_pieces() -> None:
- client = _RecordingFakeEmbeddingClient()
- original = " padded text with whitespace "
-
- _, chunk_a, chunk_b = chunked_max_similarity(client, original, "other", chunker=_chunk_to_zero_pieces)
-
- assert chunk_a.unit_type == "whole"
- assert chunk_a.text == original # original whitespace preserved, not stripped
- assert client.embed_calls.count(original) == 1
-
-
-def test_falls_back_to_whole_text_when_chunker_returns_exactly_one_piece() -> None:
- client = _RecordingFakeEmbeddingClient()
- original = " padded text with whitespace "
-
- _, chunk_a, chunk_b = chunked_max_similarity(client, original, "other", chunker=_chunk_to_one_piece)
-
- assert chunk_a.unit_type == "whole"
- assert chunk_a.text == original # the chunker's stripped version must NOT be used
- assert client.embed_calls.count(original) == 1
- # Exactly one embedding call for this document -- the chunker's own
- # (normalized) chunk is never embedded once the fallback applies.
- assert client.embed_calls.count(original.strip()) == 0
-
-
-def test_uses_chunker_output_directly_when_it_returns_two_or_more_pieces() -> None:
- client = _RecordingFakeEmbeddingClient()
-
- _, chunk_a, chunk_b = chunked_max_similarity(
- client, "abcdefgh", "ijklmnop", chunker=_chunk_to_two_pieces
- )
-
- assert chunk_a.unit_type == "paragraph"
- assert chunk_b.unit_type == "paragraph"
- # Both documents chunk into 2 pieces each via _chunk_to_two_pieces --
- # the fallback must NOT engage, so every chunk gets its own embed call.
- assert len(client.embed_calls) == 4
+from lineageweave.embedding_client import ContextualOrchestratorEmbeddingClient
def test_orchestrator_embedding_client_submits_and_polls_batch(monkeypatch) -> None:
@@ -94,7 +10,13 @@ def test_orchestrator_embedding_client_submits_and_polls_batch(monkeypatch) -> N
def fake_post_json(url, payload, *, headers, timeout):
calls.append(("post", url, payload, headers))
- return {"batch_id": "synthetic-batch", "status": "queued", "model": "resolved-embedding"}
+ return {
+ "batch_id": "synthetic-batch",
+ "status": "queued",
+ "model": "resolved-embedding",
+ "poll_after_ms": 1,
+ "job_retention_ms": 60_000,
+ }
def fake_get_json(url, *, headers, timeout, service_peer_name):
assert service_peer_name == "contextual-orchestrator"
@@ -125,6 +47,56 @@ def fake_get_json(url, *, headers, timeout, service_peer_name):
assert calls[2][2]["model"] == "resolved-embedding"
+def test_orchestrator_embedding_client_polls_through_pending_status(monkeypatch) -> None:
+ """A server-declared cadence remains mandatory on each pending poll envelope."""
+ responses = iter(
+ [
+ {
+ "batch_id": "synthetic-batch",
+ "status": "running",
+ "model": "resolved-embedding",
+ "poll_after_ms": 1,
+ "job_retention_ms": 60_000,
+ },
+ {
+ "batch_id": "synthetic-batch",
+ "status": "completed",
+ "model": "resolved-embedding",
+ "poll_after_ms": 1,
+ "job_retention_ms": 60_000,
+ "embeddings": [{"index": 0, "embedding": [1.0, 2.0]}],
+ },
+ ]
+ )
+ get_calls = []
+
+ def fake_post_json(url, payload, *, headers, timeout):
+ return {
+ "batch_id": "synthetic-batch",
+ "status": "queued",
+ "model": "resolved-embedding",
+ "poll_after_ms": 1,
+ "job_retention_ms": 60_000,
+ }
+
+ def fake_get_json(url, *, headers, timeout, service_peer_name):
+ get_calls.append(url)
+ return next(responses)
+
+ monkeypatch.setattr("lineageweave.embedding_client.post_json", fake_post_json)
+ monkeypatch.setattr("lineageweave.embedding_client.get_json", fake_get_json)
+ monkeypatch.setattr("lineageweave.embedding_client.time.sleep", lambda _seconds: None)
+ client = ContextualOrchestratorEmbeddingClient(
+ "http://orchestrator:8000", "synthetic-token"
+ )
+
+ assert client.embed_many(["first"]) == [[1.0, 2.0]]
+ assert get_calls == [
+ "http://orchestrator:8000/v1/batch/embeddings/synthetic-batch",
+ "http://orchestrator:8000/v1/batch/embeddings/synthetic-batch",
+ ]
+
+
def test_orchestrator_embedding_client_submits_index_aligned_provenance(monkeypatch) -> None:
"""Each bulk input carries its own source metadata and cost attribution."""
captured = {}
diff --git a/tests/test_embedding_client_edges.py b/tests/test_embedding_client_edges.py
index 2fa205429..81ac56f58 100644
--- a/tests/test_embedding_client_edges.py
+++ b/tests/test_embedding_client_edges.py
@@ -3,6 +3,8 @@
import pytest
from lineageweave import embedding_client
+from lineageweave.http_client import json_request_body
+from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata
def test_missing_embedding_configuration_returns_null_client() -> None:
@@ -15,20 +17,46 @@ def test_missing_embedding_configuration_returns_null_client() -> None:
def test_empty_batch_does_not_call_orchestrator(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(embedding_client, "post_json", lambda *_args, **_kwargs: pytest.fail("unexpected call"))
- client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", "model")
+ client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key")
assert client.embed_many([]) == []
@pytest.mark.parametrize("field", ["input_attributions", "input_metadata"])
def test_per_input_context_must_align_with_texts(field: str) -> None:
client = embedding_client.ContextualOrchestratorEmbeddingClient(
- "http://orchestrator", "key", "model"
+ "http://orchestrator", "key"
)
with pytest.raises(ValueError, match=field):
client.embed_many(["first", "second"], **{field: [{"key": "value"}]})
+def test_batch_capabilities_require_positive_integer_limits(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ embedding_client,
+ "get_json",
+ lambda *_args, **_kwargs: {
+ "max_request_body_bytes": 65_536,
+ "max_inputs": 2048,
+ "max_total_tokens": 300_000,
+ "max_tokens_per_part": 280_000,
+ "max_chars_per_part": 240_000,
+ "poll_after_ms": 1_000,
+ "job_retention_ms": 60_000,
+ },
+ )
+ client = embedding_client.ContextualOrchestratorEmbeddingClient(
+ "http://orchestrator", "key"
+ )
+ assert client.batch_capabilities()["max_request_body_bytes"] == 65_536
+
+ monkeypatch.setattr(embedding_client, "get_json", lambda *_args, **_kwargs: {})
+ with pytest.raises(ValueError, match="capabilities are incomplete"):
+ client.batch_capabilities()
+
+
def test_immediate_embedding_response_is_ordered(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
embedding_client,
@@ -41,12 +69,20 @@ def test_immediate_embedding_response_is_ordered(monkeypatch: pytest.MonkeyPatch
]
},
)
- client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator/v1", "key", "model")
+ client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator/v1", "key")
assert client.embed_many(["a", "b"]) == [[1.0], [2.0]]
def test_batch_response_polls_until_complete(monkeypatch: pytest.MonkeyPatch) -> None:
- responses = iter([{"batch_id": "batch-1", "status": "pending", "model": "model"}])
+ responses = iter([
+ {
+ "batch_id": "batch-1",
+ "status": "pending",
+ "model": "model",
+ "poll_after_ms": 1_000,
+ "job_retention_ms": 60_000,
+ }
+ ])
monkeypatch.setattr(embedding_client, "post_json", lambda *_args, **_kwargs: next(responses))
monkeypatch.setattr(
embedding_client,
@@ -58,7 +94,7 @@ def test_batch_response_polls_until_complete(monkeypatch: pytest.MonkeyPatch) ->
)
monkeypatch.setattr(embedding_client.time, "sleep", lambda _seconds: None)
monkeypatch.setattr(embedding_client.time, "monotonic", lambda: 0.0)
- client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", "model", timeout=1)
+ client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", timeout=1)
assert client.embed_many(["a"]) == [[0.5]]
@@ -70,9 +106,11 @@ def test_failed_batch_raises_without_fallback(monkeypatch: pytest.MonkeyPatch) -
"batch_id": "batch-1",
"status": "failed",
"model": "model",
+ "poll_after_ms": 1_000,
+ "job_retention_ms": 60_000,
},
)
- client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", "model")
+ client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key")
with pytest.raises(RuntimeError, match="did not complete"):
client.embed_many(["a"])
@@ -85,10 +123,12 @@ def test_batch_timeout_raises(monkeypatch: pytest.MonkeyPatch) -> None:
"batch_id": "batch-1",
"status": "pending",
"model": "model",
+ "poll_after_ms": 1_000,
+ "job_retention_ms": 1_000,
},
)
monkeypatch.setattr(embedding_client.time, "monotonic", iter([0.0, 2.0]).__next__)
- client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", "model", timeout=1)
+ client = embedding_client.ContextualOrchestratorEmbeddingClient("http://orchestrator", "key", timeout=1)
with pytest.raises(TimeoutError, match="timed out"):
client.embed_many(["a"])
@@ -129,9 +169,30 @@ def embed(self, text: str) -> list[float]:
return [float(len(text))]
monkeypatch.setattr(embedding_client, "ContextualOrchestratorEmbeddingClient", Delegate)
- client = embedding_client.OpenAiCompatibleEmbeddingClient("http://orchestrator", "key", "model")
+ client = embedding_client.OpenAiCompatibleEmbeddingClient("http://orchestrator", "key")
assert client.embed("abc") == [3.0]
-def test_cosine_similarity_returns_zero_for_zero_vector() -> None:
- assert embedding_client.cosine_similarity([0.0], [1.0]) == 0.0
+def test_embedding_clients_do_not_accept_a_caller_selected_model() -> None:
+ with pytest.raises(TypeError):
+ embedding_client.ContextualOrchestratorEmbeddingClient(
+ "http://orchestrator", "key", "caller-model"
+ )
+ with pytest.raises(TypeError):
+ embedding_client.OpenAiCompatibleEmbeddingClient(
+ "http://orchestrator", "key", "caller-model"
+ )
+
+
+def test_batch_body_size_matches_post_scoped_orchestrator_wire_body() -> None:
+ """The advertised ceiling includes the injected post session field."""
+ client = embedding_client.ContextualOrchestratorEmbeddingClient(
+ "http://orchestrator", "synthetic-key"
+ )
+ payload = client.batch_payload(["synthetic semantic unit"])
+ metadata = build_post_llm_metadata("synthetic-post", {})
+
+ with use_llm_metadata(metadata):
+ assert client.batch_request_body_size(["synthetic semantic unit"]) == len(
+ json_request_body(payload, include_orchestrator_session=True)
+ )
diff --git a/tests/test_http_client_edges.py b/tests/test_http_client_edges.py
index 5edcebf24..d22ae9aba 100644
--- a/tests/test_http_client_edges.py
+++ b/tests/test_http_client_edges.py
@@ -187,6 +187,55 @@ def capture_request(*_args: object, **kwargs: object) -> tuple[int, bytes]:
assert b'"lineageweave_post_id": "synthetic-post"' in captured_body
+def test_post_json_exposes_only_validated_admission_deferral(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The exact bounded retry contract becomes a typed control signal."""
+
+ def deferred_request(*_args: object, **kwargs: object) -> tuple[int, bytes]:
+ kwargs["response_control_headers"]["retry-after"] = "30"
+ return (
+ 503,
+ b'{"error":{"code":"no_viable_agent","detail":{"retry_after_seconds":30}}}',
+ )
+
+ monkeypatch.setattr(http_client, "_request", deferred_request)
+ with pytest.raises(http_client.HttpAdmissionDeferred) as captured:
+ http_client.post_json(
+ "https://gateway.example/v1/chat/completions",
+ {},
+ headers={},
+ timeout=1,
+ )
+
+ assert captured.value.retry_after_seconds == 30
+ assert "no_viable_agent" not in str(captured.value)
+
+
+def test_post_json_rejects_mismatched_admission_delay(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Conflicting header/body delays remain an ordinary unavailable response."""
+
+ def mismatched_request(*_args: object, **kwargs: object) -> tuple[int, bytes]:
+ kwargs["response_control_headers"]["retry-after"] = "31"
+ return (
+ 503,
+ b'{"error":{"code":"no_viable_agent","detail":{"retry_after_seconds":30}}}',
+ )
+
+ monkeypatch.setattr(http_client, "_request", mismatched_request)
+ with pytest.raises(http_client.HttpClientError, match="HTTP 503") as captured:
+ http_client.post_json(
+ "https://gateway.example/v1/chat/completions",
+ {},
+ headers={},
+ timeout=1,
+ )
+
+ assert not isinstance(captured.value, http_client.HttpAdmissionDeferred)
+
+
def test_request_preserves_the_url_query_in_the_http_target(
monkeypatch: pytest.MonkeyPatch,
) -> None:
diff --git a/tests/test_llm_context.py b/tests/test_llm_context.py
index 0dc6c21d0..402b4a72a 100644
--- a/tests/test_llm_context.py
+++ b/tests/test_llm_context.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import json
+
import lineageweave.http_client as http_client
from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata
@@ -39,3 +41,80 @@ def fake_request(method, url, *, body, headers, timeout):
assert seen["payload"]
assert "lineageweave_post_session_id" in seen["payload"].decode("utf-8")
assert "lineageweave_pu" in seen["payload"].decode("utf-8")
+
+
+def test_orchestrator_session_is_stable_across_modalities_and_retries(monkeypatch) -> None:
+ """One post uses one payload session for chat, VISION, and embeddings."""
+ requests: list[tuple[str, dict[str, object], dict[str, str]]] = []
+
+ def fake_request(method, url, *, body, headers, timeout):
+ del method, timeout
+ requests.append((url, json.loads(body), headers))
+ return 200, b'{"choices": []}'
+
+ monkeypatch.setattr(http_client, "_request", fake_request)
+ response_payload = {"choices": []}
+ first = build_post_llm_metadata("synthetic-post-1", {})
+ second = build_post_llm_metadata("synthetic-post-2", {})
+
+ with use_llm_metadata(first):
+ for path in (
+ "/v1/chat/completions",
+ "/v1/vision/structured",
+ "/v1/batch/embeddings",
+ "/v1/chat/completions",
+ ):
+ assert http_client.post_json(
+ f"https://orchestrator.example{path}",
+ {"input": []},
+ headers={},
+ timeout=1,
+ ) == response_payload
+ with use_llm_metadata(second):
+ http_client.post_json(
+ "https://orchestrator.example/v1/chat/completions",
+ {"input": []},
+ headers={},
+ timeout=1,
+ )
+
+ first_session = first["lineageweave_post_session_id"]
+ assert {request[1]["session_id"] for request in requests[:4]} == {first_session}
+ assert {request[2]["x-lineageweave-session-id"] for request in requests[:4]} == {
+ first_session
+ }
+ assert all(
+ request[1]["metadata"]["lineageweave_post_id"] == "synthetic-post-1"
+ for request in requests[:4]
+ )
+ assert requests[4][1]["session_id"] == second["lineageweave_post_session_id"]
+ assert requests[4][1]["session_id"] != first_session
+
+
+def test_orchestrator_session_is_not_invented_or_sent_to_other_peers(monkeypatch) -> None:
+ """Missing post context and non-orchestrator calls retain their payloads."""
+ bodies: list[dict[str, object]] = []
+
+ def fake_request(method, url, *, body, headers, timeout):
+ del method, url, headers, timeout
+ bodies.append(json.loads(body))
+ return 200, b"{}"
+
+ monkeypatch.setattr(http_client, "_request", fake_request)
+ http_client.post_json(
+ "https://orchestrator.example/v1/chat/completions",
+ {"messages": []},
+ headers={},
+ timeout=1,
+ )
+ with use_llm_metadata(build_post_llm_metadata("synthetic-post", {})):
+ http_client.post_json(
+ "https://tepp.example/v1/measurements",
+ {"observations": []},
+ headers={},
+ timeout=1,
+ service_peer_name="tepp",
+ )
+
+ assert "session_id" not in bodies[0]
+ assert "session_id" not in bodies[1]
diff --git a/tests/test_math_boundary_inventory.py b/tests/test_math_boundary_inventory.py
index d4e07eb8c..9f805e32b 100644
--- a/tests/test_math_boundary_inventory.py
+++ b/tests/test_math_boundary_inventory.py
@@ -16,6 +16,7 @@
"lineageweave/rankweave_client.py",
"lineageweave/reconstruct.py",
}
+KNOWN_LOCAL_DIRECT_VECTOR_ARITHMETIC = {"backend/app/post_chat_ingestion.py"}
def _numerical_import_files() -> set[str]:
@@ -45,3 +46,32 @@ def test_no_new_local_numerical_owner_imports() -> None:
"""Require an ADR 0208 inventory update before local numerical scope grows."""
assert _numerical_import_files() == KNOWN_LOCAL_NUMERICAL_FILES
+
+
+def test_no_new_direct_python_vector_arithmetic() -> None:
+ """Freeze direct dot/norm arithmetic until a Rust owner contract replaces it."""
+
+ found: set[str] = set()
+ for base in (ROOT / "lineageweave", ROOT / "backend" / "app"):
+ for path in base.rglob("*.py"):
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ is_sqrt = (
+ isinstance(node.func, ast.Attribute)
+ and isinstance(node.func.value, ast.Name)
+ and node.func.value.id == "math"
+ and node.func.attr == "sqrt"
+ )
+ is_product_sum = (
+ isinstance(node.func, ast.Name)
+ and node.func.id == "sum"
+ and any(
+ isinstance(child, ast.BinOp) and isinstance(child.op, ast.Mult)
+ for child in ast.walk(node)
+ )
+ )
+ if is_sqrt or is_product_sum:
+ found.add(path.relative_to(ROOT).as_posix())
+ assert found == KNOWN_LOCAL_DIRECT_VECTOR_ARITHMETIC
diff --git a/tests/test_ontology.py b/tests/test_ontology.py
index 0ef231bab..41e980423 100644
--- a/tests/test_ontology.py
+++ b/tests/test_ontology.py
@@ -89,6 +89,14 @@ def test_ontology_parses_as_valid_turtle() -> None:
assert len(graph) > 0
+def test_packaged_ontology_matches_publication_source() -> None:
+ """The installed runtime resource cannot drift from the published ontology."""
+ root = Path(__file__).resolve().parents[1]
+ packaged = root / "lineageweave" / "data" / "lineageweave-kg.ttl"
+ published = root / "docs" / "ontology" / "lineageweave-kg.ttl"
+ assert packaged.read_bytes() == published.read_bytes()
+
+
def test_every_seeded_lookup_code_is_declared_in_the_ontology() -> None:
seeded = _seeded_lookup_codes_for_covered_categories()
declared = all_declared_lookup_codes()
diff --git a/tests/test_ontology_shapes.py b/tests/test_ontology_shapes.py
index d26b4f803..67d0fc0f5 100644
--- a/tests/test_ontology_shapes.py
+++ b/tests/test_ontology_shapes.py
@@ -225,3 +225,31 @@ def test_confidence_boundary_values_are_inclusive() -> None:
)
conforms, report_text = _conforms(data)
assert conforms, f"{value} rejected:\n{report_text}"
+
+
+def test_derived_voice_assertion_requires_receipt_and_ordered_source_span() -> None:
+ """Derived voice RDF cannot omit the receipt or its exact source span."""
+ data = _representative_projection()
+ voice = URIRef(LW + "voice-assertion-alpha")
+ post = URIRef(LW + "post-alpha")
+ prov = Namespace("http://www.w3.org/ns/prov#")
+ LWn = Namespace(LW)
+ for predicate, value in (
+ (RDF.type, LWn.PostVoiceClassificationAssertion),
+ (LWn.voiceConceptCode, Literal("voc")),
+ (LWn.voiceAssertionStatus, Literal("derived")),
+ (LWn.voiceEvidenceDigest, Literal("a" * 64)),
+ (LWn.sourceRevisionDigest, Literal("b" * 64)),
+ (prov.wasDerivedFrom, post),
+ ):
+ data.add((voice, predicate, value))
+
+ conforms, report_text = _conforms(data)
+ assert not conforms
+ assert "orchestratorModelReceipt" in report_text
+
+ data.add((voice, LWn.orchestratorModelReceipt, Literal("synthetic-receipt")))
+ data.add((voice, LWn.evidenceSpanStart, Literal(0, datatype=XSD.integer)))
+ data.add((voice, LWn.evidenceSpanEnd, Literal(12, datatype=XSD.integer)))
+ conforms, report_text = _conforms(data)
+ assert conforms, report_text
diff --git a/tests/test_operations_case_analysis.py b/tests/test_operations_case_analysis.py
index 8f4d6e9e7..6ffe9d249 100644
--- a/tests/test_operations_case_analysis.py
+++ b/tests/test_operations_case_analysis.py
@@ -15,8 +15,11 @@ def test_orchestrator_request_uses_provider_neutral_auto_selector(monkeypatch) -
"""The consumer selects orchestrator routing, never a provider model name."""
captured: dict[str, object] = {}
- def post_json(_url, payload, **_kwargs):
+ captured_request: dict[str, object] = {}
+
+ def post_json(_url, payload, **kwargs):
captured.update(payload)
+ captured_request.update(kwargs)
return {"choices": [{"message": {"content": "[]"}}]}
monkeypatch.setattr(operations_case_analysis, "post_json", post_json)
@@ -27,6 +30,8 @@ def post_json(_url, payload, **_kwargs):
"",
) == ()
assert captured["model"] == "orchestrator/auto"
+ assert captured_request["timeout"] == 180.0
+ assert captured_request["headers"]["x-request-timeout-ms"] == "180000"
def test_analysis_input_digest_tracks_ordered_evidence_and_context() -> None:
diff --git a/tests/test_orchestrator_compose_embedding_contract.py b/tests/test_orchestrator_compose_embedding_contract.py
new file mode 100644
index 000000000..85efd6996
--- /dev/null
+++ b/tests/test_orchestrator_compose_embedding_contract.py
@@ -0,0 +1,49 @@
+"""Canonical Compose embedding capability contract tests."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+
+
+_ROOT = Path(__file__).parents[1]
+
+
+def test_rendered_compose_keeps_embedding_selection_upstream(tmp_path: Path) -> None:
+ """Render Compose without a LineageWeave-owned embedding selector."""
+ (tmp_path / ".env").write_text("", encoding="utf-8")
+ environment = os.environ.copy()
+ environment["HOME"] = str(tmp_path)
+ standalone_compose = shutil.which("docker-compose")
+ compose_command = [standalone_compose] if standalone_compose else ["docker", "compose"]
+ rendered = subprocess.run(
+ [*compose_command, "-f", str(_ROOT / "docker-compose.yml"), "config", "--format", "json"],
+ cwd=_ROOT,
+ env=environment,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ config = json.loads(rendered.stdout)
+ orchestrator_environment = config["services"]["orchestrator"]["environment"]
+ backend_environment = config["services"]["backend"]["environment"]
+
+ assert "LLM_GATEWAY_EMBEDDING_MODEL" not in orchestrator_environment
+ assert "LLM_GATEWAY_EMBEDDING_PROVIDER" not in orchestrator_environment
+ assert (
+ orchestrator_environment["CONTEXTUAL_ORCHESTRATOR_TOKEN"]
+ == backend_environment["ORCHESTRATOR_API_KEY"]
+ )
+ assert config["services"]["orchestrator"]["healthcheck"]["test"][-1].find(
+ "/healthz"
+ ) >= 0
+
+
+def test_lineage_clients_do_not_select_an_embedding_model() -> None:
+ """Keep provider/model ownership outside LineageWeave client services."""
+ compose = (_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
+ assert "LLM_GATEWAY_EMBEDDING_MODEL:" not in compose
+ assert "LLM_GATEWAY_EMBEDDING_PROVIDER:" not in compose
diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py
index 2c820cbfb..8d47598a0 100644
--- a/tests/test_post_content_queue.py
+++ b/tests/test_post_content_queue.py
@@ -18,6 +18,7 @@
RUNNING,
SUCCEEDED,
PostContentJobRequest,
+ defer_post_content_job,
enqueue_post_content_backfill,
record_post_content_backfill_success,
requeue_failed_post_content_job,
@@ -62,6 +63,10 @@ async def fetch(self, query: str, *args: object) -> list[dict[str, str]]:
assert "source_draft_code" in query
assert "source_deleted_flag" in query
assert "job.post_id is null or job.status_code = $1" in query
+ assert "from operations_case_analysis analysis" in query
+ assert "analysis.post_id = post.post_id" in query
+ assert "analysis.source_body_sha256 = job.source_body_sha256" in query
+ assert "from post_product_analysis analysis" in query
assert "for update of post skip locked" in query.lower()
assert args == (SUCCEEDED, True, True, 2)
return [
@@ -173,6 +178,80 @@ async def ensure(
}
+def test_backfill_requeues_complete_content_missing_operations_analysis(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A pre-extractor success is incomplete until its exact body is analyzed."""
+
+ class Transaction:
+ async def __aenter__(self) -> None:
+ return None
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ class Connection:
+ def transaction(self) -> Transaction:
+ return Transaction()
+
+ async def fetch(self, _query: str, *_args: object) -> list[dict[str, str]]:
+ return [
+ {
+ "post_id": "00000000-0000-0000-0000-000000000001",
+ "post_body": "historical success",
+ }
+ ]
+
+ async def fetchval(self, query: str, *args: object) -> bool:
+ assert "operations_case_analysis" in query
+ assert "post_product_analysis" in query
+ assert args == (
+ "00000000-0000-0000-0000-000000000001",
+ source_body_sha256("historical success"),
+ )
+ return False
+
+ class Acquire:
+ async def __aenter__(self) -> Connection:
+ return Connection()
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ class Pool:
+ def acquire(self) -> Acquire:
+ return Acquire()
+
+ async def content_complete(*_args: object, **_kwargs: object) -> bool:
+ return True
+
+ async def ensure(
+ _conn: object, post_id: str, body: str, *, content_complete: bool
+ ) -> PostContentJobRequest:
+ assert content_complete is False
+ return PostContentJobRequest(post_id, source_body_sha256(body), QUEUED, True)
+
+ async def publish(*_args: object, **_kwargs: object) -> str:
+ return "1-0"
+
+ from backend.app import post_content_queue
+
+ monkeypatch.setattr(post_content_queue, "post_content_is_complete", content_complete)
+ monkeypatch.setattr(post_content_queue, "ensure_post_content_job", ensure)
+ monkeypatch.setattr(post_content_queue, "publish_post_content_event", publish)
+ result = asyncio.run(
+ enqueue_post_content_backfill(
+ Pool(), object(), limit=1, require_embedding=True, require_structure=True
+ )
+ )
+ assert result == {
+ "selected_posts": 1,
+ "queued_posts": 1,
+ "published_events": 1,
+ "recovery_pending": 0,
+ }
+
+
@pytest.mark.parametrize("limit", [0, 201])
def test_backfill_rejects_unbounded_pages(limit: int) -> None:
"""The shared producer rejects callers that bypass the HTTP model bound."""
@@ -553,11 +632,74 @@ async def xadd(self, _stream: str, fields: dict[str, str], **_kwargs: object) ->
assert published == 2
assert client.events == [("first", "a" * 64), ("second", "b" * 64)]
+ assert "next_attempt_at <= now()" in connection.query
assert "queued_at <= now() - $2::interval" in connection.query
assert "order by queued_at" in connection.query
assert connection.args == (QUEUED, POST_CONTENT_RETRY_INTERVAL, RUNNING, STALE_RUNNING_INTERVAL, 2)
+def test_admission_deferral_requeues_exact_lease_without_consuming_attempt() -> None:
+ """A readiness miss records timing and fences the running attempt."""
+ executed: list[tuple[str, tuple[object, ...]]] = []
+
+ class FakeConnection:
+ async def fetchval(self, query: str, *_args: object) -> int:
+ assert "status_ordinal" in query
+ return 2
+
+ async def execute(self, query: str, *args: object) -> str:
+ executed.append((query, args))
+ return "UPDATE 1" if query.lstrip().startswith("update") else "INSERT 0 1"
+
+ deferred = asyncio.run(
+ defer_post_content_job(
+ FakeConnection(),
+ "00000000-0000-0000-0000-000000000001",
+ expected_attempt_count=2,
+ retry_after_seconds=30,
+ )
+ )
+
+ assert deferred is True
+ update_query, update_args = executed[0]
+ assert "attempt_count = attempt_count - 1" in update_query
+ assert "status_code = $3" in update_query
+ assert "next_attempt_at = now() + make_interval(secs => $5)" in update_query
+ assert update_args[3:5] == (2, 30)
+ assert all("provider" not in str(args).casefold() for _query, args in executed)
+
+
+def test_admission_deferral_rejects_stale_lease_without_event() -> None:
+ """A reclaimed attempt cannot defer or append status for its replacement."""
+ executed: list[str] = []
+
+ class FakeConnection:
+ async def execute(self, query: str, *_args: object) -> str:
+ executed.append(query)
+ return "UPDATE 0"
+
+ deferred = asyncio.run(
+ defer_post_content_job(
+ FakeConnection(),
+ "00000000-0000-0000-0000-000000000001",
+ expected_attempt_count=1,
+ retry_after_seconds=30,
+ )
+ )
+
+ assert deferred is False
+ assert len(executed) == 1
+
+
+def test_admission_deferral_migration_is_replay_safe() -> None:
+ """The normalized retry instant is replay-safe and indexed for recovery."""
+ migration = (
+ _ROOT / "migrations" / "0229_post_content_admission_deferral.sql"
+ ).read_text()
+ assert "add column if not exists next_attempt_at timestamptz" in migration
+ assert "create index if not exists post_content_ingestion_next_attempt_idx" in migration
+
+
def test_migration_contains_normalized_job_and_status_event_tables() -> None:
migration = (_ROOT / "migrations" / "0050_post_content_ingestion_queue.sql").read_text()
assert "create table if not exists post_content_ingestion_job" in migration
@@ -574,3 +716,17 @@ def test_migration_replay_window_includes_post_content_queue() -> None:
# 0050 therefore clears the fixed lower-bound filename gate.
assert "000[0-9]_*|001[01]_*) continue" in migrate
assert "[0-9][0-9][0-9][0-9]_*)" in migrate
+
+
+def test_superseded_body_indexes_are_not_rebuilt_before_normalized_search() -> None:
+ """Replay never builds legacy GIN indexes that the successor drops."""
+ migration_0035 = (
+ _ROOT / "migrations" / "0035_body_search_prefix.sql"
+ ).read_text()
+ migration_0036 = (
+ _ROOT / "migrations" / "0036_normalized_body_search.sql"
+ ).read_text()
+ assert "create extension if not exists pg_trgm" in migration_0035
+ assert "create index" not in migration_0035.casefold()
+ assert "create index if not exists source_post_search_prefix_trgm_idx" in migration_0036
+ assert "create index if not exists source_post_search_fts_idx" in migration_0036
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index 6b0fe4743..6323fc4c6 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -19,6 +19,19 @@
SUCCEEDED,
)
from lineageweave.operations_case_analysis import OperationsEvidenceSource
+from lineageweave.http_client import HttpAdmissionDeferred
+
+_PRODUCT_ANALYSIS = post_content_worker._persist_product_analysis_if_needed
+
+
+@pytest.fixture(autouse=True)
+def _isolate_product_analysis(monkeypatch):
+ """Keep legacy worker tests focused on their pre-product responsibility."""
+ monkeypatch.setattr(
+ post_content_worker,
+ "_persist_product_analysis_if_needed",
+ lambda *_args, **_kwargs: asyncio.sleep(0),
+ )
class _Transaction:
@@ -71,6 +84,7 @@ def _row(status: str, attempt_count: int, *, started_at: object = None) -> dict[
"job_attempt_count": attempt_count,
"job_started_at": started_at,
"job_queued_at": "queued-at",
+ "job_next_attempt_at": None,
"post_body": "A synthetic post body with a retrieval unit.",
"post_title": "Synthetic post title",
}
@@ -148,6 +162,7 @@ async def fetch(self, query: str, *_args: object):
assert sources[0].observed_at == observed_at
assert sources[0].time_axis_code == "event_occurred_at"
+ assert sources[0].source_text == "A claim was received."
def test_operations_sources_retry_when_a_source_clock_disappears(monkeypatch) -> None:
@@ -304,6 +319,33 @@ async def incomplete(*_args, **_kwargs) -> bool:
assert calls == ["checked"]
+def test_successful_job_reclaims_when_product_analysis_is_missing(monkeypatch) -> None:
+ """Historical content is reclaimed until its exact product analysis exists."""
+ row = _row(SUCCEEDED, 0)
+ row["product_analysis_source_body_sha256"] = None
+ connection = _Connection(row, values=[True])
+
+ async def complete(*_args, **_kwargs) -> bool:
+ return True
+
+ monkeypatch.setattr(post_content_worker, "post_content_is_complete", complete)
+ claimed = asyncio.run(
+ post_content_worker._claim_job(
+ _Pool(connection),
+ "00000000-0000-0000-0000-000000000001",
+ "a" * 64,
+ require_embedding=True,
+ require_structure=True,
+ )
+ )
+
+ assert claimed is row
+ assert any(
+ "attempt_count = attempt_count + 1" in query
+ for query, _args in connection.executed
+ )
+
+
def test_incomplete_provider_output_is_requeued_with_a_failure_code(monkeypatch) -> None:
connection = _Connection(values=[False, 2])
pool = _Pool(connection)
@@ -398,6 +440,87 @@ async def evidence_sources(*_args, **_kwargs):
assert called == []
+def test_product_analysis_persists_one_exact_authorized_window(monkeypatch) -> None:
+ """Product extraction reuses authorized sources and persists catalog outcomes."""
+ connection = _Connection(values=[False])
+ events: list[object] = []
+ submitted_sources: list[object] = []
+
+ async def evidence_sources(*_args, **_kwargs):
+ return (
+ OperationsEvidenceSource(
+ "post-1",
+ "Synthetic",
+ "Synthetic Product Q\nPersisted semantic evidence:\nproject: Product Alias",
+ source_text="Synthetic Product Q",
+ ),
+ OperationsEvidenceSource(
+ "post-2", "Sibling", "Sibling Product Z", source_text="Sibling Product Z"
+ ),
+ )
+
+ async def resolve(_conn, mentions):
+ events.append(mentions)
+ return (SimpleNamespace(
+ mention=mentions[0], resolution_status_code="missing", product_catalog_id=None
+ ),)
+
+ async def persist(*args):
+ events.append(args)
+
+ monkeypatch.setattr(post_content_worker, "_operations_evidence_sources", evidence_sources)
+ monkeypatch.setattr(
+ post_content_worker,
+ "ContextualOrchestratorProductExtractionClient",
+ lambda *_args: SimpleNamespace(
+ extract=lambda sources: submitted_sources.extend(sources) or (
+ post_content_worker.ProductEvidenceSource(sources[0].post_id, sources[0].text),
+ ),
+ ),
+ )
+ monkeypatch.setattr(post_content_worker, "resolve_product_mentions", resolve)
+ monkeypatch.setattr(post_content_worker, "persist_product_mentions", persist)
+
+ asyncio.run(
+ _PRODUCT_ANALYSIS(
+ _Pool(connection),
+ "post-1",
+ "a" * 64,
+ {"corporate_entity_id": "corp", "process_unit_id": "pu"},
+ SimpleNamespace(available=True),
+ "session-a",
+ "gateway",
+ "key",
+ )
+ )
+ assert len(events) == 2
+ assert len(events[1][3]) == 64
+ assert submitted_sources[0].text == "Synthetic Product Q"
+ assert [source.post_id for source in submitted_sources] == ["post-1"]
+
+
+def test_product_analysis_skips_same_digest(monkeypatch) -> None:
+ """A durable retry does not repeat product extraction for the same input."""
+ connection = _Connection(values=[True])
+
+ async def evidence_sources(*_args, **_kwargs):
+ return (OperationsEvidenceSource("post-1", "Synthetic", "Synthetic Product Q"),)
+
+ monkeypatch.setattr(post_content_worker, "_operations_evidence_sources", evidence_sources)
+ monkeypatch.setattr(
+ post_content_worker,
+ "ContextualOrchestratorProductExtractionClient",
+ lambda *_args: (_ for _ in ()).throw(AssertionError("must not call provider")),
+ )
+ asyncio.run(
+ _PRODUCT_ANALYSIS(
+ _Pool(connection), "post-1", "a" * 64,
+ {"corporate_entity_id": "corp", "process_unit_id": "pu"},
+ SimpleNamespace(available=True), "session-a", "gateway", "key",
+ )
+ )
+
+
def test_changed_evidence_window_reanalyzes_unchanged_body(monkeypatch) -> None:
"""A newly available sibling invalidates reuse without changing focal text."""
connection = _Connection(values=[False])
@@ -470,6 +593,14 @@ async def finish(_pool, _post_id, status, **_kwargs):
"_persist_operations_case_analysis_if_needed",
lambda *_args, **_kwargs: asyncio.sleep(0),
)
+ monkeypatch.setattr(
+ post_content_worker,
+ "_operations_evidence_sources",
+ lambda *_args, **_kwargs: asyncio.sleep(
+ 0,
+ result=(OperationsEvidenceSource("post-1", "Synthetic", "Evidence"),),
+ ),
+ )
monkeypatch.setattr(post_content_worker, "normalize_post_body", lambda *_args: object())
monkeypatch.setattr(
post_content_worker,
@@ -498,6 +629,89 @@ async def finish(_pool, _post_id, status, **_kwargs):
assert outcomes == [SUCCEEDED]
+def test_invalid_product_output_does_not_block_primary_post_evidence(monkeypatch) -> None:
+ """Optional product extraction cannot discard structure, embedding, or cases."""
+ outcomes: list[str] = []
+ persisted: list[str] = []
+ failures: list[tuple[str, str]] = []
+ channel_order: list[str] = []
+
+ async def claim(*_args, **_kwargs):
+ return _row(RUNNING, 1)
+
+ async def fail_product(*_args, **_kwargs):
+ channel_order.append("product")
+ raise RuntimeError("synthetic malformed product response")
+
+ async def persist_cases(*_args, **_kwargs):
+ channel_order.append("cases")
+ persisted.append("cases")
+
+ async def persist_content(*_args, **_kwargs):
+ persisted.append("content")
+
+ async def finish(_pool, _post_id, status, **_kwargs):
+ outcomes.append(status)
+
+ monkeypatch.setattr(post_content_worker, "_claim_job", claim)
+ monkeypatch.setattr(
+ post_content_worker,
+ "load_settings",
+ lambda: SimpleNamespace(
+ orchestrator_base_url="gateway", orchestrator_api_key="key"
+ ),
+ )
+ monkeypatch.setattr(
+ post_content_worker,
+ "_operations_evidence_sources",
+ lambda *_args, **_kwargs: asyncio.sleep(
+ 0,
+ result=(OperationsEvidenceSource("post-1", "Synthetic", "Evidence"),),
+ ),
+ )
+ monkeypatch.setattr(
+ post_content_worker, "_persist_product_analysis_if_needed", fail_product
+ )
+ monkeypatch.setattr(
+ post_content_worker,
+ "_persist_operations_case_analysis_if_needed",
+ persist_cases,
+ )
+ monkeypatch.setattr(post_content_worker, "normalize_post_body", lambda *_args: object())
+ monkeypatch.setattr(post_content_worker, "persist_post_content", persist_content)
+ monkeypatch.setattr(
+ post_content_worker,
+ "post_content_is_complete",
+ lambda *_args, **_kwargs: asyncio.sleep(0, result=True),
+ )
+ monkeypatch.setattr(
+ post_content_worker, "_requeue_project_missing_case_jobs", lambda *_args: asyncio.sleep(0)
+ )
+ monkeypatch.setattr(post_content_worker, "_finish_job", finish)
+ monkeypatch.setattr(
+ post_content_worker,
+ "record_server_failure",
+ lambda operation, _exc, *, outcome: failures.append((operation, outcome)),
+ )
+ client = SimpleNamespace(available=True, resolved_model="synthetic-model")
+
+ asyncio.run(
+ post_content_worker.process_post_content_job(
+ _Pool(_Connection()),
+ post_id="00000000-0000-0000-0000-000000000001",
+ source_body_digest="a" * 64,
+ vision_factory=lambda: client,
+ embedding_factory=lambda: client,
+ structure_factory=lambda: client,
+ )
+ )
+
+ assert persisted == ["cases", "content"]
+ assert channel_order == ["cases", "product"]
+ assert outcomes == [SUCCEEDED]
+ assert failures == [("product_semantic_ingestion", "provider_unavailable")]
+
+
def test_case_analysis_persists_before_content_provider_failure(monkeypatch) -> None:
"""Independent case evidence survives a later structure or embedding outage."""
connection = _Connection(values=[False, 2])
@@ -654,6 +868,62 @@ async def persist(*_args, **_kwargs):
assert record.failure_outcome == "provider_unavailable"
+def test_no_viable_agent_defers_without_consuming_failure_budget(monkeypatch) -> None:
+ """Provider admission refusal uses the exact durable deferral transition."""
+ connection = _Connection()
+ pool = _Pool(connection)
+ deferred: list[tuple[int, int]] = []
+
+ async def claim(*_args, **_kwargs):
+ return _row(RUNNING, 0)
+
+ async def no_viable(*_args, **_kwargs):
+ raise HttpAdmissionDeferred(30)
+
+ async def evidence_sources(*_args, **_kwargs):
+ return ()
+
+ async def defer(*_args, expected_attempt_count: int, retry_after_seconds: int, **_kwargs):
+ deferred.append((expected_attempt_count, retry_after_seconds))
+ return True
+
+ monkeypatch.setattr(post_content_worker, "_claim_job", claim)
+ monkeypatch.setattr(
+ post_content_worker,
+ "_persist_operations_case_analysis_if_needed",
+ no_viable,
+ )
+ monkeypatch.setattr(
+ post_content_worker,
+ "_operations_evidence_sources",
+ evidence_sources,
+ )
+ monkeypatch.setattr(post_content_worker, "defer_post_content_job", defer)
+ monkeypatch.setattr(
+ post_content_worker,
+ "load_settings",
+ lambda: SimpleNamespace(
+ orchestrator_base_url="http://orchestrator",
+ orchestrator_api_key="synthetic-token",
+ ),
+ )
+ client = SimpleNamespace(available=True)
+
+ asyncio.run(
+ post_content_worker.process_post_content_job(
+ pool,
+ post_id="00000000-0000-0000-0000-000000000001",
+ source_body_digest="a" * 64,
+ vision_factory=lambda: client,
+ embedding_factory=lambda: client,
+ structure_factory=lambda: client,
+ )
+ )
+
+ assert deferred == [(1, 30)]
+ assert not any("post_content_ingestion_failed" in str(args) for _, args in connection.executed)
+
+
def test_unexpected_worker_error_is_classified_as_internal(monkeypatch, caplog) -> None:
"""Unexpected worker defects stay internal while their value remains private."""
caplog.set_level("ERROR", logger="lineageweave.observability")
diff --git a/tests/test_product_semantics.py b/tests/test_product_semantics.py
new file mode 100644
index 000000000..1072f9f21
--- /dev/null
+++ b/tests/test_product_semantics.py
@@ -0,0 +1,127 @@
+"""Tests for evidence-bound product semantic extraction."""
+
+from lineageweave.product_semantics import (
+ ContextualOrchestratorProductExtractionClient,
+ ProductEvidenceSource,
+ ProductMention,
+ normalize_product_alias,
+ parse_product_mentions,
+ product_analysis_input_sha256,
+ resolve_product_mention,
+)
+import pytest
+
+
+def test_parse_product_mentions_binds_exact_source_span() -> None:
+ source = ProductEvidenceSource("post-a", "Synthetic Model Q supports the test.")
+ parsed = parse_product_mentions(
+ '[{"product_name":"Synthetic Model Q","evidence_post_id":"post-a",'
+ '"evidence_text":"Synthetic Model Q"}]',
+ (source,),
+ )
+ assert parsed == (
+ ProductMention(
+ "Synthetic Model Q", "Synthetic Model Q", "post-a", source.input_sha256
+ ),
+ )
+ assert len(product_analysis_input_sha256((source,))) == 64
+
+
+def test_parse_product_mentions_rejects_uncited_and_duplicate_output() -> None:
+ source = ProductEvidenceSource("post-a", "Synthetic Model Q")
+ assert parse_product_mentions(
+ '[{"product_name":"Other","evidence_post_id":"post-a",'
+ '"evidence_text":"Other"}]',
+ (source,),
+ ) is None
+ item = (
+ '{"product_name":"Synthetic Model Q","evidence_post_id":"post-a",'
+ '"evidence_text":"Synthetic Model Q"}'
+ )
+ assert parse_product_mentions(f"[{item},{item}]", (source,)) is None
+
+
+def test_parse_product_mentions_rejects_invalid_shapes() -> None:
+ source = ProductEvidenceSource("post-a", "Synthetic Model Q")
+ assert parse_product_mentions("not-json", (source,)) is None
+ assert parse_product_mentions("{}", (source,)) is None
+ assert parse_product_mentions("[1]", (source,)) is None
+ assert parse_product_mentions(
+ '[{"product_name":"","evidence_post_id":"post-a","evidence_text":"x"}]',
+ (source,),
+ ) is None
+
+
+def test_catalog_resolution_is_unique_missing_or_tie() -> None:
+ mention = ProductMention(" Product Q ", "Product Q", "post-a", "a" * 64)
+ assert normalize_product_alias(" PRODUCT Q ") == "product q"
+ unique = resolve_product_mention(mention, ("catalog-a", "catalog-a"))
+ missing = resolve_product_mention(mention, ())
+ tie = resolve_product_mention(mention, ("catalog-a", "catalog-b"))
+ unavailable = resolve_product_mention(mention, None)
+ assert (unique.resolution_status_code, unique.product_catalog_id) == (
+ "unique",
+ "catalog-a",
+ )
+ assert (missing.resolution_status_code, missing.product_catalog_id) == (
+ "missing",
+ None,
+ )
+ assert (tie.resolution_status_code, tie.product_catalog_id) == ("tie", None)
+ assert (unavailable.resolution_status_code, unavailable.product_catalog_id) == (
+ "unavailable",
+ None,
+ )
+
+
+def test_orchestrator_product_client_uses_auto_and_timeout(monkeypatch) -> None:
+ captured: dict[str, object] = {}
+
+ def fake_post(url, payload, *, headers, timeout):
+ captured.update(url=url, payload=payload, headers=headers, timeout=timeout)
+ return {
+ "choices": [
+ {
+ "message": {
+ "content": '[{"product_name":"Synthetic Model Q",'
+ '"evidence_post_id":"post-a","evidence_text":"Synthetic Model Q"}]'
+ }
+ }
+ ]
+ }
+
+ monkeypatch.setattr("lineageweave.product_semantics.post_json", fake_post)
+ source = ProductEvidenceSource("post-a", "Synthetic Model Q")
+ result = ContextualOrchestratorProductExtractionClient(
+ "https://orchestrator.invalid/", "secret", timeout=12.5
+ ).extract((source,))
+ assert result[0].evidence_post_id == "post-a"
+ assert captured["url"] == "https://orchestrator.invalid/v1/chat/completions"
+ assert captured["payload"]["model"] == "orchestrator/auto"
+ assert captured["headers"] == {
+ "authorization": "Bearer secret",
+ "x-request-timeout-ms": "12500",
+ }
+
+
+def test_orchestrator_product_client_rejects_invalid_evidence(monkeypatch) -> None:
+ monkeypatch.setattr(
+ "lineageweave.product_semantics.post_json",
+ lambda *args, **kwargs: {"choices": [{"message": {"content": "{}"}}]},
+ )
+ with pytest.raises(RuntimeError, match="invalid product evidence"):
+ ContextualOrchestratorProductExtractionClient("https://x", "secret").extract(
+ (ProductEvidenceSource("post-a", "Synthetic Model Q"),)
+ )
+
+
+def test_orchestrator_product_client_normalizes_malformed_envelope(monkeypatch) -> None:
+ """Malformed provider content is a bounded product-validation failure."""
+ monkeypatch.setattr(
+ "lineageweave.product_semantics.post_json",
+ lambda *args, **kwargs: {"choices": [{"message": {"content": None}}]},
+ )
+ with pytest.raises(RuntimeError, match="invalid product evidence"):
+ ContextualOrchestratorProductExtractionClient("https://x", "secret").extract(
+ (ProductEvidenceSource("post-a", "Synthetic Model Q"),)
+ )
diff --git a/tests/test_real_provider_integration.py b/tests/test_real_provider_integration.py
index b2a151688..cfbc505e8 100644
--- a/tests/test_real_provider_integration.py
+++ b/tests/test_real_provider_integration.py
@@ -16,11 +16,7 @@
import pytest
from lineageweave.adjudication_client import ContextualOrchestratorAdjudicationClient
-from lineageweave.embedding_client import (
- ContextualOrchestratorEmbeddingClient,
- chunked_max_similarity,
- cosine_similarity,
-)
+from lineageweave.embedding_client import ContextualOrchestratorEmbeddingClient
from lineageweave.fixtures import ambiguous_keyman_post
from lineageweave.image_content import orchestrator_vision_client
from lineageweave.keyman_extraction import (
@@ -42,57 +38,15 @@
reason="set LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL and LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY to run",
)
def test_contextual_orchestrator_embedding_client_returns_real_vectors() -> None:
- """A real embedding call, with a real, meaningful assertion: two labels
- about the same synthetic topic must cosine-score higher than two about
- unrelated synthetic topics -- not just "the call didn't crash".
- """
+ """A real embedding call returns a complete provider-owned vector."""
client = ContextualOrchestratorEmbeddingClient(
base_url=_ORCHESTRATOR_BASE_URL, api_key=_ORCHESTRATOR_API_KEY, model=_EMBEDDING_MODEL
)
a = client.embed("Quarterly budget review meeting notes")
- b = client.embed("Budget review follow-up: revised quarterly numbers")
- c = client.embed("Office parking lot repaving schedule")
-
- related_score = cosine_similarity(a, b)
- unrelated_score = cosine_similarity(a, c)
-
- assert 0.0 <= related_score <= 1.0
- assert 0.0 <= unrelated_score <= 1.0
- assert related_score > unrelated_score
assert len(a) > 8
-@pytest.mark.skipif(
- not (_ORCHESTRATOR_BASE_URL and _ORCHESTRATOR_API_KEY),
- reason="set LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL and LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY to run",
-)
-def test_chunked_embedding_finds_a_relevant_unit_buried_in_a_longer_document() -> None:
- """The real case chunking exists for: a short relevant passage sitting
- inside a much longer, mostly-irrelevant document. Whole-document
- embedding dilutes the relevant passage with everything around it;
- chunked max-pooled similarity should not.
- """
- client = ContextualOrchestratorEmbeddingClient(
- base_url=_ORCHESTRATOR_BASE_URL, api_key=_ORCHESTRATOR_API_KEY, model=_EMBEDDING_MODEL
- )
-
- query = "Quarterly budget review meeting notes"
- long_document = (
- "Office parking lot repaving schedule for the north campus.\n\n"
- "New badge access policy for the west entrance starting next month.\n\n"
- "Budget review follow-up: revised quarterly numbers and next steps.\n\n"
- "Cafeteria menu rotation for the coming season.\n\n"
- "Reminder about the annual fire drill scheduled for next week."
- )
-
- chunked_score, _best_a, best_b = chunked_max_similarity(client, query, long_document)
- whole_document_score = cosine_similarity(client.embed(query), client.embed(long_document))
-
- assert "Budget review" in best_b.text
- assert chunked_score > whole_document_score
-
-
@pytest.mark.skipif(
not (_ORCHESTRATOR_BASE_URL and _ORCHESTRATOR_API_KEY),
reason="set LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL and LINEAGEWEAVE_TEST_ORCHESTRATOR_API_KEY to run",
diff --git a/tests/test_static_sql_review_contracts.py b/tests/test_static_sql_review_contracts.py
index d09df90d8..f991e2324 100644
--- a/tests/test_static_sql_review_contracts.py
+++ b/tests/test_static_sql_review_contracts.py
@@ -29,7 +29,7 @@
)
ASYNC_STATEMENT_METHODS = {"execute", "fetch", "fetchrow", "fetchval"}
SQL_REVIEW_RULE = "python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli"
-EXPECTED_SQL_SUPPRESSION_COUNT = 37
+EXPECTED_SQL_SUPPRESSION_COUNT = 38
@pytest.mark.parametrize("relative_path", SQL_REVIEW_PATHS)
diff --git a/tests/test_worker_health.py b/tests/test_worker_health.py
new file mode 100644
index 000000000..e3f0831f2
--- /dev/null
+++ b/tests/test_worker_health.py
@@ -0,0 +1,46 @@
+"""Tests for progress-based durable-worker health reporting."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+
+import pytest
+
+from backend.app import worker_health
+
+
+def test_health_requires_progress_between_probes(tmp_path: Path) -> None:
+ """A live PID with an unchanged event-loop heartbeat is unhealthy."""
+ heartbeat = tmp_path / "heartbeat"
+ state = tmp_path / "state"
+
+ assert worker_health.heartbeat_has_advanced(heartbeat, state) is False
+ heartbeat.write_text("1", encoding="ascii")
+ assert worker_health.heartbeat_has_advanced(heartbeat, state) is True
+ assert worker_health.heartbeat_has_advanced(heartbeat, state) is False
+ heartbeat.write_text("2", encoding="ascii")
+ assert worker_health.heartbeat_has_advanced(heartbeat, state) is True
+
+
+def test_malformed_heartbeat_fails_closed(tmp_path: Path) -> None:
+ """Malformed progress evidence is never reported as healthy."""
+ heartbeat = tmp_path / "heartbeat"
+ heartbeat.write_text("not-a-counter", encoding="ascii")
+
+ assert worker_health.heartbeat_has_advanced(heartbeat, tmp_path / "state") is False
+
+
+def test_heartbeat_records_before_first_sleep(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Startup publishes progress before the first broker-poll interval."""
+ heartbeat = tmp_path / "heartbeat"
+
+ async def cancel_after_first_record(_seconds: float) -> None:
+ raise asyncio.CancelledError
+
+ monkeypatch.setattr(worker_health.asyncio, "sleep", cancel_after_first_record)
+ with pytest.raises(asyncio.CancelledError):
+ asyncio.run(worker_health.run_worker_heartbeat(heartbeat))
+ assert int(heartbeat.read_text(encoding="ascii")) >= 0
From aa55d898bee72bdb4ce2dd8c9ea2eaa49752b51d Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 17:56:13 +0900
Subject: [PATCH 190/393] chore: pin direct-conduct readiness runtime
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index c1cd9505e..c0a6aee5c 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/071c8f84e10fbd591d3915b1d5e932223cd1c640.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/12061f4dfdd599f12860aa503243d777bafbe2c1.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/071c8f84e10fbd591d3915b1d5e932223cd1c640.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/12061f4dfdd599f12860aa503243d777bafbe2c1.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index c9a45edd8..dbeaa51df 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `071c8f84e10fbd591d3915b1d5e932223cd1c640`. The pin remains explicit
+commit `12061f4dfdd599f12860aa503243d777bafbe2c1`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index f3073cdbc..cb3adfba1 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "071c8f84e10fbd591d3915b1d5e932223cd1c640"
+ expected_embedding_contract_commit = "12061f4dfdd599f12860aa503243d777bafbe2c1"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 8d5a39f930da7909ee92fd7d3a34aeccdc348852 Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 18:06:42 +0900
Subject: [PATCH 191/393] chore: pin structured admission runtime
---
docker/contextual-orchestrator/Dockerfile | 4 ++--
docs/adr/0083-orchestrator-runtime-commit-pin.md | 2 +-
tests/test_documentation_hygiene.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker/contextual-orchestrator/Dockerfile b/docker/contextual-orchestrator/Dockerfile
index c0a6aee5c..e7fbdc9e9 100644
--- a/docker/contextual-orchestrator/Dockerfile
+++ b/docker/contextual-orchestrator/Dockerfile
@@ -4,7 +4,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl build-esse
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
ENV PATH=/root/.cargo/bin:$PATH
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/12061f4dfdd599f12860aa503243d777bafbe2c1.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/ce5b63d7bef0f34f4d8a7eae9fa029a7b51568b1.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& python -m pip install --no-cache-dir 'maturin>=1.8,<2' \
@@ -17,7 +17,7 @@ WORKDIR /app
# Reuse the upstream implementation without copying it into LineageWeave.
# Pin the runtime to a reviewed immutable upstream commit; model selection,
# structured synthesis, and reasoning policy stay in contextual-orchestrator.
-ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/12061f4dfdd599f12860aa503243d777bafbe2c1.tar.gz /tmp/contextual-orchestrator.tar.gz
+ADD https://github.com/ContextualWisdomLab/contextual-orchestrator/archive/ce5b63d7bef0f34f4d8a7eae9fa029a7b51568b1.tar.gz /tmp/contextual-orchestrator.tar.gz
RUN mkdir /tmp/contextual-orchestrator \
&& tar -xzf /tmp/contextual-orchestrator.tar.gz --strip-components=1 -C /tmp/contextual-orchestrator \
&& cp -R /tmp/contextual-orchestrator/contextual_orchestrator /app/contextual_orchestrator \
diff --git a/docs/adr/0083-orchestrator-runtime-commit-pin.md b/docs/adr/0083-orchestrator-runtime-commit-pin.md
index dbeaa51df..63ac68ced 100644
--- a/docs/adr/0083-orchestrator-runtime-commit-pin.md
+++ b/docs/adr/0083-orchestrator-runtime-commit-pin.md
@@ -15,7 +15,7 @@ multi-agent.
## Decision
`docker/contextual-orchestrator/Dockerfile` pins the downloaded archive to
-commit `12061f4dfdd599f12860aa503243d777bafbe2c1`. The pin remains explicit
+commit `ce5b63d7bef0f34f4d8a7eae9fa029a7b51568b1`. The pin remains explicit
and immutable until the reviewed upstream change is superseded; it is not a
moving `main` reference and it is not a LineageWeave monkey patch.
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
index cb3adfba1..ba453be00 100644
--- a/tests/test_documentation_hygiene.py
+++ b/tests/test_documentation_hygiene.py
@@ -118,7 +118,7 @@ def test_role_catalog_identity_migration_is_wired() -> None:
def test_orchestrator_runtime_pin_matches_adr() -> None:
"""The image pin and ADR must describe the same immutable upstream commit."""
- expected_embedding_contract_commit = "12061f4dfdd599f12860aa503243d777bafbe2c1"
+ expected_embedding_contract_commit = "ce5b63d7bef0f34f4d8a7eae9fa029a7b51568b1"
dockerfile = (
_ROOT / "docker" / "contextual-orchestrator" / "Dockerfile"
).read_text(encoding="utf-8")
From 93c5b6f683066fe96da21311d43d71aaa17ffd0c Mon Sep 17 00:00:00 2001
From: Codex
Date: Wed, 26 Aug 2026 18:19:21 +0900
Subject: [PATCH 192/393] fix(stack): preserve current main contracts
---
frontend/src/App.tsx | 26 +++++++++++++++++++++++++-
frontend/src/styles/tokens.test.ts | 12 ++++++++++++
pyproject.toml | 4 +++-
3 files changed, 40 insertions(+), 2 deletions(-)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e1f9868c6..ccfb54355 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4845,7 +4845,14 @@ export function AskAgentPanel({
setAsking(true);
setError(null);
try {
- setAnswer(await askAgent(accessToken, normalized));
+ setAnswer(
+ await askAgent(
+ accessToken,
+ normalized,
+ verifyExternal,
+ knowledgeCutoff ? new Date(knowledgeCutoff).toISOString() : undefined,
+ ),
+ );
setAnsweredQuestion(normalized);
} catch (err) {
setAnswer(null);
@@ -4874,6 +4881,23 @@ export function AskAgentPanel({
rows={4}
/>
+
+ setVerifyExternal(event.target.checked)}
+ />
+ {t("Check eligible public claims")}
+
+
+ {t("Knowledge cutoff (optional)")}
+ setKnowledgeCutoff(event.target.value)}
+ />
+
{t("Ask")}
diff --git a/frontend/src/styles/tokens.test.ts b/frontend/src/styles/tokens.test.ts
index 6e47505e8..b37840d6c 100644
--- a/frontend/src/styles/tokens.test.ts
+++ b/frontend/src/styles/tokens.test.ts
@@ -187,6 +187,18 @@ describe("design tokens", () => {
expect(rule).toContain("display: inline-flex");
expect(rule).toContain("align-items: center");
});
+
+ it("keeps public-verification layout on shared tokens", () => {
+ expect(publicClaimCss).not.toMatch(/#[0-9a-fA-F]{3,8}/);
+ for (const token of [
+ "--space-panel-block",
+ "--space-control-gap",
+ "--color-border",
+ "--size-control-min",
+ ]) {
+ expect(publicClaimCss).toContain(`var(${token})`);
+ }
+ });
});
describe("secondary disclosure toggle touch targets", () => {
diff --git a/pyproject.toml b/pyproject.toml
index e7a736f0c..443b97079 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,7 +50,9 @@ backend = [
"pyjwt[crypto]>=2.8.0",
# Speaks RESP; works against Valkey (a Redis-protocol-compatible fork)
# as well as real Redis. Used for the post-activity event stream.
- "redis>=5.0.0",
+ "redis>=5.0.1",
+ # Authenticated Streamable HTTP resource server (ADR 0218).
+ "mcp==2.0.0",
# Owner arithmetic from protected main (ADR 0208). Product-specific lineage
# contracts remain unavailable until a domain-neutral owner contract lands.
# PyO3/maturin source builds need the pinned Rust toolchain.
From f075b0ef09144921af42328667e742359a2278f7 Mon Sep 17 00:00:00 2001
From: Codex