diff --git a/frontend/src/PostBody.tsx b/frontend/src/PostBody.tsx
new file mode 100644
index 000000000..3ff77b537
--- /dev/null
+++ b/frontend/src/PostBody.tsx
@@ -0,0 +1,33 @@
+import { splitPostBody, type PostBodySegment } from "./postBodyDisplay";
+
+function renderSegment(segment: PostBodySegment, index: number) {
+ switch (segment.kind) {
+ case "text":
+ return (
+
+ {segment.text}
+
+ );
+ case "image":
+ return (
+
+
+
+ Image from this post. Extract Keyman or ask a question to read text
+ inside it.
+
+
+ );
+ default: {
+ const _exhaustive: never = segment;
+ throw new Error(`unexpected post body segment: ${JSON.stringify(_exhaustive)}`);
+ }
+ }
+}
+
+export function PostBody({ body }: { body: string }) {
+ return
{splitPostBody(body).map(renderSegment)}
;
+}
diff --git a/frontend/src/analysisRunCopy.test.ts b/frontend/src/analysisRunCopy.test.ts
new file mode 100644
index 000000000..3f73d80f4
--- /dev/null
+++ b/frontend/src/analysisRunCopy.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from "vitest";
+import type { AnalysisRun, AnalysisRunKindCode, AnalysisRunStatusCode } from "./api";
+import {
+ analysisRunAccessibleName,
+ analysisRunCaption,
+ analysisRunCorpusHint,
+ analysisRunNextAction,
+} from "./analysisRunCopy";
+
+function demoRun(overrides: Partial
= {}): AnalysisRun {
+ return {
+ analysis_run_id: "run-demo",
+ run_kind_code: "analysis_run_lineage",
+ run_kind_label: "Lineage reconstruction",
+ scope_kind_code: "analysis_scope_corporate_entity",
+ scope_kind_label: "Corporate entity",
+ scope_entity_name: "Demo Corp",
+ status_code: "analysis_status_pending",
+ status_label: "Pending",
+ knowledge_cutoff: "2026-01-12T12:00:00Z",
+ requested_at: "2026-01-12T12:30:00Z",
+ source_counts: [],
+ ...overrides,
+ };
+}
+
+const KIND_LABEL: Record = {
+ analysis_run_lineage: "Lineage reconstruction",
+ analysis_run_tepp: "TEPP measurement",
+ analysis_run_report: "Period report",
+};
+
+const STATUS_LABEL: Record = {
+ analysis_status_pending: "Pending",
+ analysis_status_running: "Running",
+ analysis_status_succeeded: "Succeeded",
+ analysis_status_failed: "Failed",
+ analysis_status_cancelled: "Cancelled",
+};
+
+describe("analysisRunAccessibleName", () => {
+ it("keeps the caption as kind · status · entity", () => {
+ expect(analysisRunCaption(demoRun())).toBe("Lineage reconstruction · Pending · Demo Corp");
+ });
+
+ it.each([
+ {
+ kind: "analysis_run_tepp" as const,
+ status: "analysis_status_failed" as const,
+ mustInclude: "connect the measurement service",
+ mustExclude: "reconstruction",
+ },
+ {
+ kind: "analysis_run_lineage" as const,
+ status: "analysis_status_failed" as const,
+ mustInclude: "retry reconstruction",
+ mustExclude: "measurement service",
+ },
+ {
+ kind: "analysis_run_report" as const,
+ status: "analysis_status_failed" as const,
+ mustInclude: "rebuild the period report",
+ mustExclude: "measurement service",
+ },
+ {
+ kind: "analysis_run_tepp" as const,
+ status: "analysis_status_pending" as const,
+ mustInclude: "this is not a calibrated result",
+ mustExclude: "Reconstruction",
+ },
+ {
+ kind: "analysis_run_tepp" as const,
+ status: "analysis_status_running" as const,
+ mustInclude: "this is not a calibrated result",
+ mustExclude: "Reconstruction",
+ },
+ {
+ kind: "analysis_run_lineage" as const,
+ status: "analysis_status_pending" as const,
+ mustInclude: "Reconstruction has not started yet",
+ mustExclude: "measurement",
+ },
+ {
+ kind: "analysis_run_report" as const,
+ status: "analysis_status_pending" as const,
+ mustInclude: "The report has not been built yet",
+ mustExclude: "Reconstruction",
+ },
+ {
+ kind: "analysis_run_tepp" as const,
+ status: "analysis_status_cancelled" as const,
+ mustInclude: "cancelled before a calibrated result",
+ mustExclude: "Reconstruction",
+ },
+ ])(
+ "puts the $status $kind next action in the list name",
+ ({ kind, status, mustInclude, mustExclude }) => {
+ const run = demoRun({
+ run_kind_code: kind,
+ run_kind_label: KIND_LABEL[kind],
+ status_code: status,
+ status_label: STATUS_LABEL[status],
+ });
+ const name = analysisRunAccessibleName(run);
+ const nextAction = analysisRunNextAction(run);
+ expect(nextAction).toBeTruthy();
+ expect(name).toBe(`Open analysis run: ${analysisRunCaption(run)}. ${nextAction}`);
+ expect(name).toMatch(new RegExp(mustInclude, "i"));
+ expect(name).not.toMatch(new RegExp(mustExclude));
+ },
+ );
+
+ it("does not claim a calibrated result on a succeeded TEPP caption-only name", () => {
+ const run = demoRun({
+ run_kind_code: "analysis_run_tepp",
+ run_kind_label: "TEPP measurement",
+ status_code: "analysis_status_succeeded",
+ status_label: "Succeeded",
+ });
+ expect(analysisRunNextAction(run)).toBeNull();
+ expect(analysisRunAccessibleName(run)).toBe(
+ "Open analysis run: TEPP measurement · Succeeded · Demo Corp",
+ );
+ expect(analysisRunCorpusHint(run)).toBe("These posts are the cutoff corpus this TEPP run measured.");
+ });
+});
diff --git a/frontend/src/analysisRunCopy.ts b/frontend/src/analysisRunCopy.ts
new file mode 100644
index 000000000..ef44f647e
--- /dev/null
+++ b/frontend/src/analysisRunCopy.ts
@@ -0,0 +1,199 @@
+import type { AnalysisRun, AnalysisRunKindCode, AnalysisRunStatusCode } from "./api";
+
+/**
+ * Visible list caption. Kind, status, and entity stay in this order
+ * (ADR 0014). The machine `failure_code` stays off this string.
+ */
+export function analysisRunCaption(run: AnalysisRun): string {
+ return [run.run_kind_label, run.status_label, run.scope_entity_name ?? run.scope_kind_label]
+ .filter(Boolean)
+ .join(" · ");
+}
+
+function unexpectedKindNextAction(
+ unexpected: never,
+ failed: boolean,
+): string {
+ void unexpected;
+ return failed
+ ? "Open this run to see why it failed, then retry from a current snapshot."
+ : "Open this run to confirm its next step. The registered kind is not lineage, TEPP, or a period report.";
+}
+
+function pendingNextAction(kind: AnalysisRunKindCode): string {
+ switch (kind) {
+ case "analysis_run_lineage":
+ return "Open this run to confirm which posts it will use. 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.";
+ 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:
+ return unexpectedKindNextAction(kind, false);
+ }
+}
+
+function failedNextAction(kind: AnalysisRunKindCode): string {
+ switch (kind) {
+ case "analysis_run_tepp":
+ return "Open this run to see why it failed, then connect the measurement service and 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":
+ return "Open this run to see why it failed, then rebuild the period report from a current snapshot.";
+ default:
+ return unexpectedKindNextAction(kind, true);
+ }
+}
+
+function runningNextAction(kind: AnalysisRunKindCode): string {
+ switch (kind) {
+ case "analysis_run_lineage":
+ return "Open this run to confirm which posts reconstruction is using. Reconstruction has not finished yet.";
+ case "analysis_run_tepp":
+ return "Open this run to confirm which posts TEPP is measuring. Measurement is in progress — this is not a calibrated result.";
+ case "analysis_run_report":
+ return "Open this run to confirm which posts the period report is using. The report has not been built yet.";
+ default:
+ return unexpectedKindNextAction(kind, false);
+ }
+}
+
+function cancelledNextAction(kind: AnalysisRunKindCode): string {
+ switch (kind) {
+ case "analysis_run_lineage":
+ return "This run was cancelled before reconstruction finished. Request a new reconstruction from a current snapshot.";
+ case "analysis_run_tepp":
+ return "This run was cancelled before a calibrated result. Connect the measurement service, then re-run.";
+ case "analysis_run_report":
+ return "This run was cancelled before the period report was built. Rebuild the period report from a current snapshot.";
+ default:
+ return unexpectedKindNextAction(kind, true);
+ }
+}
+
+/**
+ * Next action for the home list and detail (ADR 0014).
+ *
+ * The machine `failure_code` stays on detail history. Copy is pinned to
+ * registered kinds so a pending or running TEPP row is not mistaken for
+ * reconstruction, and a failed lineage row is not mistaken for a missing
+ * TEPP transport. Unknown wire codes stay off the sentence.
+ */
+export function analysisRunNextAction(run: AnalysisRun): string | null {
+ const status: AnalysisRunStatusCode | null = run.status_code;
+ switch (status) {
+ case "analysis_status_pending":
+ return pendingNextAction(run.run_kind_code);
+ case "analysis_status_failed":
+ return failedNextAction(run.run_kind_code);
+ case "analysis_status_running":
+ return runningNextAction(run.run_kind_code);
+ case "analysis_status_cancelled":
+ return cancelledNextAction(run.run_kind_code);
+ case "analysis_status_succeeded":
+ case null:
+ return null;
+ default: {
+ const unexpected: never = status;
+ void unexpected;
+ return "Open this run to confirm its current status before acting.";
+ }
+ }
+}
+
+/**
+ * List-button accessible name (WCAG 2.2 SC 4.1.2 / AccName 1.1).
+ *
+ * `aria-label` replaces the button contents, so the next-action sentence
+ * must be in the name or a screen reader only hears the caption.
+ */
+export function analysisRunAccessibleName(run: AnalysisRun): string {
+ const caption = analysisRunCaption(run);
+ const nextAction = analysisRunNextAction(run);
+ return nextAction ? `Open analysis run: ${caption}. ${nextAction}` : `Open analysis run: ${caption}`;
+}
+
+/**
+ * Empty-corpus copy that tells the operator what to do next.
+ */
+export function analysisRunEmptyPostsHint(run: AnalysisRun): string {
+ switch (run.run_kind_code) {
+ case "analysis_run_tepp":
+ return (
+ "No posts were available at this cutoff for TEPP to measure. " +
+ "Open a later run, or ask an administrator to capture a newer snapshot."
+ );
+ case "analysis_run_lineage":
+ return (
+ "No posts were available at this cutoff for reconstruction. " +
+ "Open a later run, or ask an administrator to capture a newer snapshot."
+ );
+ case "analysis_run_report":
+ return (
+ "No posts were available at this cutoff for the period report. " +
+ "Open a later run, or ask an administrator to capture a newer snapshot."
+ );
+ default: {
+ const unexpected: never = run.run_kind_code;
+ void unexpected;
+ return (
+ "No posts were available at this cutoff. Open a later run, or ask an " +
+ "administrator to capture a newer snapshot."
+ );
+ }
+ }
+}
+
+/**
+ * Corpus copy for a TEPP run that already has cutoff posts.
+ *
+ * Those titles are the measurement bag, not a reconstruction result.
+ * Pending or running must not claim a calibrated measurement.
+ */
+export function analysisRunCorpusHint(run: AnalysisRun): string | null {
+ if (run.run_kind_code !== "analysis_run_tepp") return null;
+ switch (run.status_code) {
+ case "analysis_status_failed":
+ return (
+ "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " +
+ "transport, then re-run, to replace Failed with a calibrated result."
+ );
+ case "analysis_status_succeeded":
+ return "These posts are the cutoff corpus this TEPP run measured.";
+ case "analysis_status_pending":
+ case "analysis_status_running":
+ return "These posts are the cutoff corpus TEPP will measure once this run finishes.";
+ case "analysis_status_cancelled":
+ return (
+ "These posts are the cutoff corpus this TEPP run would have measured. " +
+ "The run was cancelled before a calibrated result."
+ );
+ case null:
+ return "These posts are the cutoff corpus attached to this TEPP run.";
+ default: {
+ const unexpected: never = run.status_code;
+ void unexpected;
+ return "These posts are the cutoff corpus attached to this TEPP run.";
+ }
+ }
+}
+
+/**
+ * Next action when a cutoff title opens the live post (ADR 0016).
+ *
+ * Post-body versioning is a later slice. Until then the operator must
+ * compare the opened body with this run's cutoff instead of treating
+ * today's text as reconstructed evidence.
+ */
+export function analysisRunLivePostWarning(cutoffIso: string): string {
+ const cutoffDate = cutoffIso.slice(0, 10);
+ return (
+ `Opening a title shows the live post. Compare it with cutoff ${cutoffDate} ` +
+ "before you treat the body as reconstructed evidence — it may have changed after this run."
+ );
+}
+
+export function analysisRunLivePostButtonLabel(postTitle: string): string {
+ return `Open live post (may have changed after cutoff): ${postTitle}`;
+}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 2b6692776..3385d5179 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -26,6 +26,7 @@ export interface Keyman {
person_side_code: string;
person_side_label?: string;
mention_context: string | null;
+ last_known_job_title: string | null;
affiliations: Affiliation[];
}
@@ -72,19 +73,30 @@ export interface VocEvidence {
counterparties: VocEvidenceCounterparty[];
}
+export type RelatedNodeType =
+ | "node_person"
+ | "node_post"
+ | "node_corporate_entity"
+ | "node_team";
+
export interface RelatedNode {
node_id: string;
- node_type_code: string;
+ node_type_code: RelatedNodeType | string;
relevance: number;
label?: string;
person_side_code?: string;
+ person_side_label?: string;
ontology_iri?: string;
ontology_label?: string;
}
export interface PostRoleResponsibility {
- person_name: string;
+ actor_name: string;
responsibility: string;
+ actor_type_code: string;
+ affiliated_organization_name: string | null;
+ catalog_node_id?: string | null;
+ catalog_node_type_code?: string | null;
}
export interface PostAiSummary {
@@ -280,6 +292,13 @@ export function fetchRelatedEntity(
return backendFetch(`/api/corporate-entities/${entityId}/related`, accessToken);
}
+export function fetchRelatedTeam(
+ accessToken: string,
+ teamId: string,
+): Promise<{ team_id: string; team_name: string; related: RelatedNode[] }> {
+ return backendFetch(`/api/teams/${teamId}/related`, accessToken);
+}
+
export function extractPostKeymen(
accessToken: string,
postId: string,
@@ -488,3 +507,75 @@ export function deriveCommitment(accessToken: string, postId: string): Promise {
return backendFetch("/api/calendar", accessToken);
}
+
+export interface AnalysisRunCount {
+ count_type_code: string;
+ count_type_label: string;
+ count_value: number;
+}
+
+/** Registry kinds from `analysis_run.run_kind_code` (migration 0018). */
+export type AnalysisRunKindCode =
+ | "analysis_run_lineage"
+ | "analysis_run_report"
+ | "analysis_run_tepp";
+
+/** Registry statuses from `analysis_run_status_event.status_code`. */
+export type AnalysisRunStatusCode =
+ | "analysis_status_pending"
+ | "analysis_status_running"
+ | "analysis_status_succeeded"
+ | "analysis_status_failed"
+ | "analysis_status_cancelled";
+
+export interface AnalysisRunStatusEvent {
+ status_ordinal: number;
+ status_code: AnalysisRunStatusCode;
+ status_label: string;
+ occurred_at: string;
+ failure_code?: string;
+}
+
+export interface AnalysisRun {
+ analysis_run_id: string;
+ run_kind_code: AnalysisRunKindCode;
+ run_kind_label: string;
+ scope_kind_code: string;
+ scope_kind_label: string;
+ scope_entity_name?: string;
+ status_code: AnalysisRunStatusCode | null;
+ status_label: string | null;
+ knowledge_cutoff: string;
+ requested_at: string;
+ source_counts: AnalysisRunCount[];
+ status_history?: AnalysisRunStatusEvent[];
+ visible_posts?: { post_id: string; post_title: string }[];
+ code_revision_sha?: string;
+ configuration_sha256?: string;
+}
+
+export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> {
+ return backendFetch("/api/analysis-runs", accessToken);
+}
+
+export function fetchAnalysisRun(accessToken: string, analysisRunId: string): Promise {
+ return backendFetch(`/api/analysis-runs/${analysisRunId}`, accessToken);
+}
+
+export interface CreateAnalysisRunRequest {
+ run_kind_code?: string;
+ scope_kind_code?: string;
+ corporate_entity_id?: string;
+ knowledge_cutoff?: string;
+ idempotency_key: string;
+}
+
+export function createAnalysisRun(
+ accessToken: string,
+ request: CreateAnalysisRunRequest,
+): Promise {
+ return backendFetch("/api/analysis-runs", accessToken, {
+ method: "POST",
+ body: JSON.stringify(request),
+ });
+}
diff --git a/frontend/src/components/AnalysisRunListButton.stories.tsx b/frontend/src/components/AnalysisRunListButton.stories.tsx
new file mode 100644
index 000000000..df0c15148
--- /dev/null
+++ b/frontend/src/components/AnalysisRunListButton.stories.tsx
@@ -0,0 +1,85 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import type { AnalysisRun, AnalysisRunKindCode, AnalysisRunStatusCode } from "../api";
+import { AnalysisRunListButton } from "./AnalysisRunListButton";
+
+const KIND_LABEL: Record = {
+ analysis_run_lineage: "Lineage reconstruction",
+ analysis_run_tepp: "TEPP measurement",
+ analysis_run_report: "Period report",
+};
+
+const STATUS_LABEL: Record = {
+ analysis_status_pending: "Pending",
+ analysis_status_running: "Running",
+ analysis_status_succeeded: "Succeeded",
+ analysis_status_failed: "Failed",
+ analysis_status_cancelled: "Cancelled",
+};
+
+function demoRun(
+ kind: AnalysisRunKindCode,
+ status: AnalysisRunStatusCode,
+): AnalysisRun {
+ return {
+ analysis_run_id: `run-demo-${kind}-${status}`,
+ run_kind_code: kind,
+ run_kind_label: KIND_LABEL[kind],
+ scope_kind_code: "analysis_scope_corporate_entity",
+ scope_kind_label: "Corporate entity",
+ scope_entity_name: "Demo Corp",
+ status_code: status,
+ status_label: STATUS_LABEL[status],
+ knowledge_cutoff: "2026-01-12T12:00:00Z",
+ requested_at: "2026-01-12T12:34:00Z",
+ source_counts: [
+ {
+ count_type_code: "analysis_count_document",
+ count_type_label: "Documents",
+ count_value: 3,
+ },
+ ],
+ };
+}
+
+const meta = {
+ title: "AnalysisRuns/AnalysisRunListButton",
+ component: AnalysisRunListButton,
+ args: {
+ run: demoRun("analysis_run_tepp", "analysis_status_pending"),
+ onOpen: () => undefined,
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const PendingTepp: Story = {};
+
+export const RunningTepp: Story = {
+ args: { run: demoRun("analysis_run_tepp", "analysis_status_running") },
+};
+
+export const FailedTepp: Story = {
+ args: { run: demoRun("analysis_run_tepp", "analysis_status_failed") },
+};
+
+export const CancelledTepp: Story = {
+ args: { run: demoRun("analysis_run_tepp", "analysis_status_cancelled") },
+};
+
+export const PendingLineage: Story = {
+ args: { run: demoRun("analysis_run_lineage", "analysis_status_pending") },
+};
+
+export const FailedLineage: Story = {
+ args: { run: demoRun("analysis_run_lineage", "analysis_status_failed") },
+};
+
+export const PendingPeriodReport: Story = {
+ args: { run: demoRun("analysis_run_report", "analysis_status_pending") },
+};
+
+export const FailedPeriodReport: Story = {
+ args: { run: demoRun("analysis_run_report", "analysis_status_failed") },
+};
diff --git a/frontend/src/components/AnalysisRunListButton.test.tsx b/frontend/src/components/AnalysisRunListButton.test.tsx
new file mode 100644
index 000000000..72ff25922
--- /dev/null
+++ b/frontend/src/components/AnalysisRunListButton.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type { AnalysisRun } from "../api";
+import { AnalysisRunListButton } from "./AnalysisRunListButton";
+
+function pendingTepp(): AnalysisRun {
+ return {
+ analysis_run_id: "run-demo-tepp",
+ run_kind_code: "analysis_run_tepp",
+ run_kind_label: "TEPP measurement",
+ scope_kind_code: "analysis_scope_corporate_entity",
+ scope_kind_label: "Corporate entity",
+ scope_entity_name: "Demo Corp",
+ status_code: "analysis_status_pending",
+ status_label: "Pending",
+ knowledge_cutoff: "2026-01-12T12:00:00Z",
+ requested_at: "2026-01-12T12:34:00Z",
+ source_counts: [
+ {
+ count_type_code: "analysis_count_document",
+ count_type_label: "Documents",
+ count_value: 3,
+ },
+ ],
+ };
+}
+
+describe("AnalysisRunListButton", () => {
+ it("opens the run and names the pending TEPP next action", async () => {
+ const onOpen = vi.fn();
+ render( );
+ const button = screen.getByRole("button", {
+ name: "Open analysis run: TEPP measurement · Pending · Demo Corp. Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result.",
+ });
+ expect(button).toHaveTextContent("3 documents");
+ expect(button).not.toHaveTextContent("Reconstruction");
+ await userEvent.click(button);
+ expect(onOpen).toHaveBeenCalledWith("run-demo-tepp");
+ });
+});
diff --git a/frontend/src/components/AnalysisRunListButton.tsx b/frontend/src/components/AnalysisRunListButton.tsx
new file mode 100644
index 000000000..fce936aee
--- /dev/null
+++ b/frontend/src/components/AnalysisRunListButton.tsx
@@ -0,0 +1,41 @@
+import type { AnalysisRun } from "../api";
+import {
+ analysisRunAccessibleName,
+ analysisRunCaption,
+ analysisRunNextAction,
+} from "../analysisRunCopy";
+
+export type AnalysisRunListButtonProps = {
+ run: AnalysisRun;
+ onOpen: (analysisRunId: string) => void;
+};
+
+/**
+ * Home-list control for one analysis-run row (ADR 0014).
+ *
+ * Next action: activate the button to open the run. The accessible name
+ * includes the kind-specific next-action sentence when one exists.
+ */
+export function AnalysisRunListButton({ run, onOpen }: AnalysisRunListButtonProps) {
+ const caption = analysisRunCaption(run);
+ const nextAction = analysisRunNextAction(run);
+ const documentCount = run.source_counts.find(
+ (count) => count.count_type_code === "analysis_count_document",
+ );
+ return (
+ onOpen(run.analysis_run_id)}
+ >
+ {caption}
+ {documentCount && (
+
+ {documentCount.count_value} {documentCount.count_type_label.toLowerCase()}
+
+ )}
+ {nextAction && {nextAction} }
+
+ );
+}
diff --git a/frontend/src/components/CitationChip.stories.tsx b/frontend/src/components/CitationChip.stories.tsx
new file mode 100644
index 000000000..2cce5cc22
--- /dev/null
+++ b/frontend/src/components/CitationChip.stories.tsx
@@ -0,0 +1,24 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { CitationChip } from "./CitationChip";
+
+const meta = {
+ title: "Evidence/CitationChip",
+ component: CitationChip,
+ args: {
+ postId: "post-demo-public",
+ postTitle: "Demo public post",
+ onOpenEvidence: () => undefined,
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const LongTitle: Story = {
+ args: {
+ postTitle: "Demo Corp January cutoff reconstruction notes",
+ },
+};
diff --git a/frontend/src/components/CitationChip.test.tsx b/frontend/src/components/CitationChip.test.tsx
new file mode 100644
index 000000000..ff7b948a0
--- /dev/null
+++ b/frontend/src/components/CitationChip.test.tsx
@@ -0,0 +1,21 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { CitationChip } from "./CitationChip";
+
+describe("CitationChip", () => {
+ it("opens the cited post when the buyer clicks the chip", async () => {
+ const onOpenEvidence = vi.fn();
+ render(
+ ,
+ );
+ await userEvent.click(
+ screen.getByRole("button", { name: "Open evidence: Demo public post" }),
+ );
+ expect(onOpenEvidence).toHaveBeenCalledWith("post-demo-public");
+ });
+});
diff --git a/frontend/src/components/CitationChip.tsx b/frontend/src/components/CitationChip.tsx
new file mode 100644
index 000000000..8ac3f4908
--- /dev/null
+++ b/frontend/src/components/CitationChip.tsx
@@ -0,0 +1,27 @@
+export type CitationChipProps = {
+ postId: string;
+ postTitle: string;
+ onOpenEvidence: (postId: string) => void;
+};
+
+/**
+ * Opens the cited source post from a reconstruction caption.
+ *
+ * Next action: click the chip to read the evidence that grounded the claim.
+ */
+export function CitationChip({
+ postId,
+ postTitle,
+ onOpenEvidence,
+}: CitationChipProps) {
+ return (
+ onOpenEvidence(postId)}
+ >
+ {postTitle}
+
+ );
+}
diff --git a/frontend/src/components/PopupCloseButton.stories.tsx b/frontend/src/components/PopupCloseButton.stories.tsx
new file mode 100644
index 000000000..c697987a4
--- /dev/null
+++ b/frontend/src/components/PopupCloseButton.stories.tsx
@@ -0,0 +1,23 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { PopupCloseButton } from "./PopupCloseButton";
+
+const meta = {
+ title: "Chrome/PopupCloseButton",
+ component: PopupCloseButton,
+ args: {
+ label: "Close evidence panel",
+ onClose: () => undefined,
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const EvidencePanel: Story = {};
+
+export const PostPopup: Story = {
+ args: {
+ label: "Close",
+ },
+};
diff --git a/frontend/src/components/PopupCloseButton.test.tsx b/frontend/src/components/PopupCloseButton.test.tsx
new file mode 100644
index 000000000..b72e0b91d
--- /dev/null
+++ b/frontend/src/components/PopupCloseButton.test.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { PopupCloseButton } from "./PopupCloseButton";
+
+describe("PopupCloseButton", () => {
+ it("closes the evidence panel when the buyer clicks close", async () => {
+ const onClose = vi.fn();
+ render(
+ ,
+ );
+ await userEvent.click(
+ screen.getByRole("button", { name: "Close evidence panel" }),
+ );
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend/src/components/PopupCloseButton.tsx b/frontend/src/components/PopupCloseButton.tsx
new file mode 100644
index 000000000..129b07297
--- /dev/null
+++ b/frontend/src/components/PopupCloseButton.tsx
@@ -0,0 +1,22 @@
+export type PopupCloseButtonProps = {
+ onClose: () => void;
+ label: string;
+};
+
+/**
+ * Closes the evidence panel or post popup.
+ *
+ * Next action: click to return to the list or the reconstruction view.
+ */
+export function PopupCloseButton({ onClose, label }: PopupCloseButtonProps) {
+ return (
+
+ ×
+
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 5fb331302..011ad42fd 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,13 +1,20 @@
+@import "./styles/tokens.css";
+
:root {
- --text: #6b6375;
- --text-h: #08060d;
- --bg: #fff;
- --border: #e5e4e7;
- --code-bg: #f4f3ec;
- --accent: #aa3bff;
- --accent-bg: rgba(170, 59, 255, 0.1);
- --accent-border: rgba(170, 59, 255, 0.5);
+ --text: var(--color-text);
+ --text-h: var(--color-text-heading);
+ --bg: var(--color-background);
+ --border: var(--color-border);
+ --code-bg: var(--color-code-background);
+ --accent: var(--color-accent);
+ --accent-bg: var(--color-accent-background);
+ --accent-border: var(--color-accent-border);
--social-bg: rgba(244, 243, 236, 0.5);
+ --post-body-gap: 0.75rem;
+ --post-image-padding: 0.75rem;
+ --post-image-radius: 8px;
+ --post-image-border: var(--border);
+ --post-image-bg: var(--code-bg);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
@@ -32,14 +39,14 @@
@media (prefers-color-scheme: dark) {
:root {
- --text: #9ca3af;
- --text-h: #f3f4f6;
- --bg: #16171d;
- --border: #2e303a;
- --code-bg: #1f2028;
- --accent: #c084fc;
- --accent-bg: rgba(192, 132, 252, 0.15);
- --accent-border: rgba(192, 132, 252, 0.5);
+ --text: var(--color-text);
+ --text-h: var(--color-text-heading);
+ --bg: var(--color-background);
+ --border: var(--color-border);
+ --code-bg: var(--color-code-background);
+ --accent: var(--color-accent);
+ --accent-bg: var(--color-accent-background);
+ --accent-border: var(--color-accent-border);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
diff --git a/frontend/src/postBodyDisplay.test.ts b/frontend/src/postBodyDisplay.test.ts
new file mode 100644
index 000000000..f3092cea6
--- /dev/null
+++ b/frontend/src/postBodyDisplay.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+import { splitPostBody } from "./postBodyDisplay";
+
+/** 1x1 transparent PNG — the same synthetic fixture the Python vision tests use. */
+const TINY_PNG_B64 =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
+
+describe("splitPostBody", () => {
+ it("leaves a plain-text post unchanged so existing popups keep their wording", () => {
+ expect(splitPostBody("The full body text.")).toEqual([
+ { kind: "text", text: "The full body text." },
+ ]);
+ });
+
+ it("keeps comparison operators that look like broken HTML", () => {
+ expect(splitPostBody("qty < 50 and price > 10")).toEqual([
+ { kind: "text", text: "qty < 50 and price > 10" },
+ ]);
+ });
+
+ it("renders a data-URI image as its own segment and never leaks the raw base64 into text", () => {
+ const html =
+ `Quote attached.
Please confirm.
`;
+ const segments = splitPostBody(html);
+
+ expect(segments).toEqual([
+ { kind: "text", text: "Quote attached." },
+ {
+ kind: "image",
+ src: `data:image/png;base64,${TINY_PNG_B64}`,
+ mimeType: "image/png",
+ position: html.indexOf(" {
+ const html =
+ `between
` +
+ ` `;
+ const segments = splitPostBody(html);
+ expect(segments.map((segment) => segment.kind)).toEqual(["image", "text", "image"]);
+ expect(segments[1]).toEqual({ kind: "text", text: "between" });
+ expect(segments[0]?.kind === "image" && segments[0].position).toBe(0);
+ expect(segments[2]?.kind === "image" && segments[2].position).toBeGreaterThan(0);
+ });
+
+ it("tells the operator to re-export when the base64 payload is not decodable", () => {
+ const html = ' ';
+ expect(splitPostBody(html)).toEqual([
+ {
+ kind: "text",
+ text: "Embedded image could not be decoded. Re-export the source post and open it again.",
+ },
+ ]);
+ });
+
+ it("does not turn a remote http img into a loaded image", () => {
+ const html = 'See
end
';
+ const segments = splitPostBody(html);
+ expect(segments.every((segment) => segment.kind === "text")).toBe(true);
+ expect(segments.map((segment) => (segment.kind === "text" ? segment.text : "")).join(" ")).toContain(
+ "See",
+ );
+ expect(JSON.stringify(segments)).not.toContain("https://example.test");
+ });
+});
diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts
new file mode 100644
index 000000000..c6ea29fdd
--- /dev/null
+++ b/frontend/src/postBodyDisplay.ts
@@ -0,0 +1,72 @@
+/**
+ * Split a raw `post_body` into text and in-place data-URI images.
+ *
+ * The popup used to dump the source string, so a buyer who opened a post
+ * with an embedded invoice saw a base64 wall instead of the picture.
+ * Only `data:image/...;base64,...` payloads are turned into images —
+ * remote `http(s)` img tags are stripped, never fetched.
+ */
+
+export type PostBodySegment =
+ | { kind: "text"; text: string }
+ | { kind: "image"; src: string; mimeType: string; position: number };
+
+const DATA_URI_IMG =
+ / ]*\bsrc\s*=\s*["']data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)["'][^>]*>/gi;
+
+const HTML_TAG = /<\/?[a-zA-Z][^>]*>/g;
+
+const UNDECODEABLE_IMAGE =
+ "Embedded image could not be decoded. Re-export the source post and open it again.";
+
+function stripHtmlTags(text: string): string {
+ return text.replace(HTML_TAG, " ").replace(/\s+/g, " ").trim();
+}
+
+function isDecodableBase64(raw: string): boolean {
+ if (raw.length === 0) {
+ return false;
+ }
+ try {
+ atob(raw);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function pushText(segments: PostBodySegment[], raw: string): void {
+ const text = stripHtmlTags(raw);
+ if (text) {
+ segments.push({ kind: "text", text });
+ }
+}
+
+export function splitPostBody(body: string): PostBodySegment[] {
+ const segments: PostBodySegment[] = [];
+ const pattern = new RegExp(DATA_URI_IMG.source, "gi");
+ let lastIndex = 0;
+ let match = pattern.exec(body);
+ while (match !== null) {
+ pushText(segments, body.slice(lastIndex, match.index));
+ const mimeType = match[1];
+ const rawB64 = match[2].replace(/\s+/g, "");
+ if (isDecodableBase64(rawB64)) {
+ segments.push({
+ kind: "image",
+ src: `data:${mimeType};base64,${rawB64}`,
+ mimeType,
+ position: match.index,
+ });
+ } else {
+ segments.push({ kind: "text", text: UNDECODEABLE_IMAGE });
+ }
+ lastIndex = match.index + match[0].length;
+ match = pattern.exec(body);
+ }
+ pushText(segments, body.slice(lastIndex));
+ if (segments.length === 0) {
+ return [{ kind: "text", text: body }];
+ }
+ return segments;
+}
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
new file mode 100644
index 000000000..ae8616232
--- /dev/null
+++ b/frontend/src/styles/tokens.css
@@ -0,0 +1,34 @@
+:root {
+ --color-text: #6b6375;
+ --color-text-heading: #08060d;
+ --color-background: #fff;
+ --color-border: #e5e4e7;
+ --color-code-background: #f4f3ec;
+ --color-accent: #aa3bff;
+ --color-accent-background: rgba(170, 59, 255, 0.1);
+ --color-accent-border: rgba(170, 59, 255, 0.5);
+ --color-chip-border: #3335;
+ --space-chip-inline: 0.6rem;
+ --space-chip-block: 0.1rem;
+ --space-chip-gap: 0.3rem;
+ --space-close-inset: 0.75rem;
+ --radius-chip: 999px;
+ --font-size-close: 1.5rem;
+ --font-family-chip: ui-monospace, Consolas, monospace;
+ --lw-opacity-meta: 0.7;
+ --lw-font-size-meta: 0.85rem;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --color-text: #9ca3af;
+ --color-text-heading: #f3f4f6;
+ --color-background: #16171d;
+ --color-border: #2e303a;
+ --color-code-background: #1f2028;
+ --color-accent: #c084fc;
+ --color-accent-background: rgba(192, 132, 252, 0.15);
+ --color-accent-border: rgba(192, 132, 252, 0.5);
+ --color-chip-border: #9ca3af;
+ }
+}
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
index 6830b6f75..d054398da 100644
--- a/frontend/tsconfig.app.json
+++ b/frontend/tsconfig.app.json
@@ -22,5 +22,6 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
- "include": ["src"]
+ "include": ["src"],
+ "exclude": ["src/**/*.stories.tsx"]
}
diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py
index 0ac8e50fe..1950c39f8 100644
--- a/lineageweave/__init__.py
+++ b/lineageweave/__init__.py
@@ -15,6 +15,17 @@
from .models import Edge, Record, Tree
from .post_chat import ChatAnswer, cited_post_summaries
from .post_summary import PostSummary
+from .prov_o import (
+ PROV,
+ PROV_CLASSES,
+ PROV_QUALIFICATIONS,
+ PROV_RELATIONS,
+ PROV_RECOMMENDED_INVERSES,
+ ProvAssertion,
+ ProvGraph,
+ ProvLiteral,
+ ProvValidationError,
+)
from .reconstruct import reconstruct
from .voc_evidence import sentence_excerpts
@@ -22,7 +33,16 @@
"ChatAnswer",
"Edge",
"OrganizationRelationship",
+ "PROV",
+ "PROV_CLASSES",
+ "PROV_QUALIFICATIONS",
+ "PROV_RELATIONS",
+ "PROV_RECOMMENDED_INVERSES",
"PostSummary",
+ "ProvAssertion",
+ "ProvGraph",
+ "ProvLiteral",
+ "ProvValidationError",
"Record",
"Tree",
"build_affiliate_forest",
@@ -35,4 +55,4 @@
"sentence_excerpts",
]
-__version__ = "0.71.0"
+__version__ = "0.87.0"
diff --git a/lineageweave/corporate_hierarchy_inference.py b/lineageweave/corporate_hierarchy_inference.py
new file mode 100644
index 000000000..174ef4503
--- /dev/null
+++ b/lineageweave/corporate_hierarchy_inference.py
@@ -0,0 +1,173 @@
+"""Infers where a newly-mentioned organization sits in a Group -> Company
+-> Plant style hierarchy (e.g. "Acme Electronics South Plant" -> parent "Acme Electronics
+한국" -> parent "Acme Group") when it does not already match an existing
+``corporate_entity`` row -- the standing "통합 고객사 계열 tree AI"
+(integrated customer affiliate tree) requirement this product has
+always named, closing the gap that
+:mod:`lineageweave.corporate_hierarchy_resolution`'s similarity
+matching leaves open: matching only ever finds an ALREADY-cataloged
+entity, it never creates one, so a unseen dataset's first mention of any
+new counterparty organization stays permanently unresolved.
+
+Grounded in the same collective-entity-resolution framing
+(Bhattacharya & Getoor, 2007) already cited for
+``corporate_hierarchy_resolution`` -- this module is the natural
+extension of that same resolution pipeline to entity *creation* when no
+existing candidate matches, not a separate technique. The hierarchy
+itself is the same SKOS ``skos:broader``/``skos:narrower`` structure
+``corporate_entity_level`` (ADR 0004) already uses on top of the
+``parent_entity_id`` self-reference.
+
+Same pluggable-client, never-fake-a-missing-channel, never-trust-an-
+unverified-guess discipline as every other channel in this package: a
+proposed new entity is only ever created after
+:mod:`lineageweave.relation_verification`'s external-search
+corroboration, the same reused verification client
+:mod:`lineageweave.organization_name_resolution` already established
+this pattern for.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from functools import lru_cache
+from typing import Protocol
+
+from .http_client import post_json
+
+LEVEL_GROUP = "group"
+LEVEL_COMPANY = "company"
+LEVEL_PLANT = "plant"
+_VALID_LEVEL_CODES = frozenset({LEVEL_GROUP, LEVEL_COMPANY, LEVEL_PLANT})
+
+@lru_cache(maxsize=1)
+def required_corporate_level_codes() -> frozenset[str]:
+ """Return the level codes every migrated database registers."""
+ return _VALID_LEVEL_CODES
+
+
+@dataclass(frozen=True)
+class HierarchyProposal:
+ """One organization's proposed place in the hierarchy.
+
+ Attributes:
+ level_code: ``corporate_entity_level`` lookup code -- one of
+ ``group`` / ``company`` / ``plant``.
+ parent_name: the immediate parent organization's name the text
+ supports, or ``None`` when this organization has no parent
+ in the hierarchy the text gives evidence for (a standalone
+ group-level entity, or the text simply does not say).
+ """
+
+ level_code: str
+ parent_name: str | None
+
+
+class CorporateHierarchyInferenceClient(Protocol):
+ """Proposes a hierarchy placement for a newly-seen organization name."""
+
+ available: bool
+
+ def infer(self, organization_name: str, context_text: str) -> HierarchyProposal | None:
+ """Return a proposed placement, or ``None`` when the model
+ cannot determine one from the given context with real
+ confidence.
+
+ Implementations must raise if the call itself fails -- a failed
+ call is not the same outcome as "the model looked and proposed
+ nothing." Protocol stubs raise ``NotImplementedError`` so a
+ no-op body is never treated as a successful empty result.
+ """
+ raise NotImplementedError
+
+
+class NullCorporateHierarchyInferenceClient:
+ """No LLM orchestrator configured -- hierarchy inference is unavailable."""
+
+ available = False
+
+ def infer(self, organization_name: str, context_text: str) -> HierarchyProposal | None:
+ raise RuntimeError(
+ "NullCorporateHierarchyInferenceClient cannot infer; check .available first"
+ )
+
+
+_INFERENCE_PROMPT_TEMPLATE = """\
+The text below names an organization, "{organization_name}", that is
+not yet in our corporate hierarchy catalog. Using ONLY what the text
+itself supports (never invent a hierarchy the text gives no evidence
+for), determine:
+
+1. Its level: exactly one of "group" (a top-level conglomerate/group
+ with no parent), "company" (a company, possibly part of a group),
+ or "plant" (a specific plant/site/branch/subsidiary of a company).
+2. Its immediate parent organization's name, if the text names or
+ clearly implies one (e.g. "Acme Electronics South Plant" implies its parent is
+ "Acme Electronics"). Use null when the text gives no parent to infer, or
+ when this organization is itself a top-level group.
+
+Reply with ONLY a JSON object (no markdown fences, no prose):
+ "level": exactly "group", "company", or "plant"
+ "parent_name": string, or null
+
+If you cannot determine even the level with real confidence from the
+text, reply with exactly: UNKNOWN
+
+Text: {context}
+"""
+
+
+def parse_inference_response(content: str) -> HierarchyProposal | None:
+ """Parses the LLM's JSON reply into a `HierarchyProposal`.
+
+ Returns `None` for `UNKNOWN`, malformed JSON, or a level outside the
+ three valid codes -- a model that did not follow the contract gets
+ treated as "no proposal," never a guessed default.
+ """
+ stripped = content.strip()
+ if not stripped or stripped.upper() == "UNKNOWN":
+ return None
+ try:
+ parsed = json.loads(stripped)
+ except json.JSONDecodeError:
+ return None
+ if not isinstance(parsed, dict):
+ return None
+ level = parsed.get("level")
+ if level not in _VALID_LEVEL_CODES:
+ return None
+ parent_raw = parsed.get("parent_name")
+ parent_name = parent_raw.strip() if isinstance(parent_raw, str) and parent_raw.strip() else None
+ return HierarchyProposal(level_code=level, parent_name=parent_name)
+
+
+class ContextualOrchestratorHierarchyInferenceClient:
+ """Calls ``POST {base_url}/v1/chat/completions`` with ``mode="route"``."""
+
+ available = True
+
+ def __init__(
+ self, base_url: str, api_key: str, *, reasoning_effort: str = "medium", timeout: float = 30.0
+ ) -> None:
+ self._base_url = base_url.rstrip("/")
+ self._api_key = api_key
+ self._reasoning_effort = reasoning_effort
+ self._timeout = timeout
+
+ def infer(self, organization_name: str, context_text: str) -> HierarchyProposal | None:
+ prompt = _INFERENCE_PROMPT_TEMPLATE.format(
+ organization_name=organization_name, context=context_text
+ )
+ body = post_json(
+ f"{self._base_url}/v1/chat/completions",
+ {
+ "messages": [{"role": "user", "content": prompt}],
+ "mode": "route",
+ "reasoning_effort": self._reasoning_effort,
+ },
+ headers={"authorization": f"Bearer {self._api_key}"},
+ timeout=self._timeout,
+ )
+ content = body["choices"][0]["message"]["content"]
+ return parse_inference_response(content)
diff --git a/lineageweave/image_content.py b/lineageweave/image_content.py
index 2f6839fcd..3bbcbbe15 100644
--- a/lineageweave/image_content.py
+++ b/lineageweave/image_content.py
@@ -126,35 +126,79 @@ def describe(self, image_bytes: bytes, mime_type: str) -> ImageDescription: # p
"CAPTION: \n"
"TAGS: "
)
-# DOTALL + non-greedy so TEXT: can legitimately span multiple lines (real
-# OCR output is often multi-line) without losing everything after the
-# first newline, while still stopping at the next expected label.
-_DESCRIPTION_PATTERN = re.compile(
- r"TEXT:\s*(?P.*?)\s*CAPTION:\s*(?P.*?)\s*TAGS:\s*(?P.*)",
- re.DOTALL,
+# TEXT may legitimately span multiple lines because OCR output is often
+# multi-line. CAPTION and TAGS are explicitly single-line fields. Synthetic
+# format-variation fixtures cover common provider drift such as bolded or
+# reordered labels without allowing trailing commentary to contaminate the
+# searchable caption or tag values.
+_LABEL_LINE = re.compile(
+ r"^\s*(?:[*_`>#\-]\s*)*(TEXT|CAPTION|TAGS)(?:\s*[*_`]+)?\s*:\s*"
+ r"(?:(?:[*_`]+)(?=\s|$)\s*)?(.*)$",
+ re.IGNORECASE,
)
+_MARKDOWN_EMPHASIS_MARKERS = ("**", "__", "`", "*", "_")
class ImageDescriptionParseError(ValueError):
- """The vision provider's response didn't match the required
- TEXT/CAPTION/TAGS format -- raised instead of silently returning an
- empty ImageDescription, so a provider response-format change is
- surfaced immediately rather than quietly losing searchable content.
+ """Neither TEXT nor CAPTION could be found in the vision provider's
+ response -- raised instead of silently returning an empty
+ ImageDescription, so a provider response genuinely unusable end to
+ end is surfaced, not confused with "described nothing."
"""
+def _strip_outer_markdown_emphasis(value: str) -> str:
+ """Remove balanced outer Markdown emphasis without changing inner text."""
+ cleaned = value.strip()
+ changed = True
+ while changed:
+ changed = False
+ for marker in _MARKDOWN_EMPHASIS_MARKERS:
+ if (
+ cleaned.startswith(marker)
+ and cleaned.endswith(marker)
+ and len(cleaned) > 2 * len(marker)
+ ):
+ cleaned = cleaned[len(marker) : -len(marker)].strip()
+ changed = True
+ break
+ return cleaned
+
+
def _parse_description(content: str) -> ImageDescription:
- match = _DESCRIPTION_PATTERN.search(content)
- if match is None:
+ fields: dict[str, list[str]] = {"TEXT": [], "CAPTION": [], "TAGS": []}
+ multiline_field: str | None = None
+ for line in content.splitlines():
+ match = _LABEL_LINE.match(line)
+ if match:
+ label = match.group(1).upper()
+ remainder = _strip_outer_markdown_emphasis(match.group(2))
+ if remainder:
+ fields[label].append(remainder)
+ multiline_field = "TEXT" if label == "TEXT" else None
+ continue
+
+ if re.match(r"^\s*[*_`>#\-\s]*[A-Za-z][A-Za-z0-9 _-]*\s*:", line):
+ multiline_field = None
+ continue
+ if multiline_field == "TEXT" and line.strip():
+ fields["TEXT"].append(_strip_outer_markdown_emphasis(line))
+
+ if not fields["TEXT"] and not fields["CAPTION"]:
raise ImageDescriptionParseError(
- f"vision response did not match the required TEXT/CAPTION/TAGS format: {content!r}"
+ f"vision response had neither TEXT nor CAPTION content: {content!r}"
)
- extracted_text = match.group("text").strip()
+
+ extracted_text = "\n".join(fields["TEXT"]).strip()
if extracted_text.upper() == "NONE":
extracted_text = ""
- caption = match.group("caption").strip()
- tags_raw = match.group("tags").strip()
- tags = tuple(tag.strip() for tag in tags_raw.split(",") if tag.strip())
+ caption = "\n".join(fields["CAPTION"]).strip()
+ tags_raw = " ".join(fields["TAGS"]).strip()
+ tags = tuple(
+ cleaned
+ for tag in tags_raw.split(",")
+ if (cleaned := _strip_outer_markdown_emphasis(tag))
+ )
return ImageDescription(extracted_text=extracted_text, caption=caption, tags=tags)
diff --git a/lineageweave/keyman_extraction.py b/lineageweave/keyman_extraction.py
index 5775c0f20..7c3d79397 100644
--- a/lineageweave/keyman_extraction.py
+++ b/lineageweave/keyman_extraction.py
@@ -35,14 +35,26 @@
class PersonMention:
"""One person the extractor found in a post's text.
- ``affiliated_organization_names`` may be empty (mentioned without a
- stated affiliation) or contain more than one name (the N:N case the
- product requirement describes).
+ Attributes:
+ affiliated_organization_names: may be empty (mentioned without a
+ stated affiliation) or contain more than one name (the N:N
+ case the product requirement describes).
+ job_title: the person's title/position as the text states it
+ (e.g. "영업팀장," "구매담당"), or ``None`` when the text does
+ not say. Two different real people can share a name -- a
+ name alone is not a reliable identity key, and dropping a
+ stated title would throw away the one signal the text
+ offers to tell them apart. Persisted onto
+ ``person_affiliation.role_title`` (a schema column that
+ already existed, previously never populated) and used by
+ ``_upsert_person`` as a same-name disambiguation signal:
+ see ``backend/app/keyman_ingestion.py``.
"""
person_name: str
person_side_code: str
affiliated_organization_names: tuple[str, ...] = field(default_factory=tuple)
+ job_title: str | None = None
class KeymanExtractionClient(Protocol):
@@ -71,9 +83,13 @@ def extract(self, post_title: str, post_body: str) -> list[PersonMention]:
_EXTRACTION_PROMPT_TEMPLATE = """\
Read the post below and list every named person it mentions. For each
-person, classify which side they are on and list every organization they
+person, classify which side they are on, list every organization they
are affiliated with according to the text (a person may belong to more
-than one organization, or none if the text does not say).
+than one organization, or none if the text does not say), and give their
+job title or position if the text states one. Two different real people
+can share the same name -- a stated title/position (e.g. "sales
+manager," "purchasing lead") is real evidence for telling them apart, so
+report it whenever the text gives one rather than leaving it out.
Reply with ONLY a JSON array (no markdown fences, no prose), where each
element has exactly these fields:
@@ -82,6 +98,8 @@ def extract(self, post_title: str, post_body: str) -> list[PersonMention]:
"counterparty" (an external customer, partner, competitor, or
other outside organization)
"affiliations": a JSON array of organization name strings (can be empty)
+ "job_title": the person's stated title/position as a string, or null
+ when the text does not give one
If no people are named, reply with an empty JSON array: []
@@ -126,8 +144,15 @@ def parse_keyman_response(content: str) -> list[PersonMention]:
if not isinstance(affiliations_raw, list):
affiliations_raw = []
affiliations = tuple(a.strip() for a in affiliations_raw if isinstance(a, str) and a.strip())
+ job_title_raw = entry.get("job_title")
+ job_title = job_title_raw.strip() if isinstance(job_title_raw, str) and job_title_raw.strip() else None
mentions.append(
- PersonMention(person_name=name.strip(), person_side_code=side, affiliated_organization_names=affiliations)
+ PersonMention(
+ person_name=name.strip(),
+ person_side_code=side,
+ affiliated_organization_names=affiliations,
+ job_title=job_title,
+ )
)
return mentions
diff --git a/lineageweave/knowledge_graph.py b/lineageweave/knowledge_graph.py
index cf750e978..6af4fb4a2 100644
--- a/lineageweave/knowledge_graph.py
+++ b/lineageweave/knowledge_graph.py
@@ -129,9 +129,19 @@ def select_related_nodes(
NODE_PERSON = "node_person"
NODE_CORPORATE_ENTITY = "node_corporate_entity"
NODE_POST = "node_post"
+NODE_TEAM = "node_team"
EDGE_MENTION = "edge_mention"
EDGE_AFFILIATION = "edge_affiliation"
EDGE_CO_MENTION = "edge_co_mention"
+# ADR 0009: cross-post identity resolution for R&R team/organization
+# actors -- a team is meso-level (ADR 0007), so it gets its own mention
+# edge distinct from a person's, plus its own affiliation edge to the
+# company it belongs to (parallel to edge_affiliation for persons, kept
+# distinct rather than reused so an edge_type_code alone always tells
+# you which node types it connects, without inspecting the row).
+EDGE_MENTION_TEAM = "edge_mention_team"
+EDGE_TEAM_AFFILIATION = "edge_team_affiliation"
+EDGE_MENTION_ORGANIZATION = "edge_mention_organization"
@dataclass(frozen=True)
@@ -166,23 +176,36 @@ def knowledge_graph_edges_for_post(
post_id: str,
person_ids: Sequence[str],
person_corporate_entity_ids: Sequence[tuple[str, str]] = (),
+ team_ids: Sequence[str] = (),
+ team_corporate_entity_ids: Sequence[tuple[str, str]] = (),
+ organization_corporate_entity_ids: Sequence[str] = (),
) -> list[KnowledgeGraphEdgeSpec]:
- """Populate the three Phase 2 edge kinds for one post.
+ """Populate this post's Phase 2 + ADR 0009 edge kinds.
- person <-> post (``edge_mention``) for every mentioned person
- person <-> corporate_entity (``edge_affiliation``) for every
affiliation that resolved to a real ``corporate_entity`` row
- person <-> person (``edge_co_mention``) for every unordered pair of
people named in the same post
-
- Affiliation names that did not resolve to a ``corporate_entity`` are
- stored on ``person_affiliation`` but do not become graph edges -- a
- free-text org with no node id cannot be a knowledge_graph_edge
- endpoint. Directed storage is canonical (person -> post/org, and
- lexicographic person-id order for co-mentions); loaders treat the
- graph as undirected.
+ - team <-> post (``edge_mention_team``) for every mentioned,
+ cataloged team (ADR 0009 -- cross-post team identity)
+ - team <-> corporate_entity (``edge_team_affiliation``) for every
+ team whose parent organization resolved to a real
+ ``corporate_entity`` row
+ - corporate_entity <-> post (``edge_mention_organization``) for
+ every R&R organization actor that resolved to a real
+ ``corporate_entity`` row (ADR 0009)
+
+ Affiliation/organization names that did not resolve to a
+ ``corporate_entity`` are stored on the relevant table but do not
+ become graph edges -- a free-text org with no node id cannot be a
+ knowledge_graph_edge endpoint. Directed storage is canonical
+ (person/team/org -> post/org, and lexicographic person-id order for
+ co-mentions); loaders treat the graph as undirected.
"""
unique_person_ids = list(dict.fromkeys(person_ids))
+ unique_team_ids = list(dict.fromkeys(team_ids))
+ unique_organization_ids = list(dict.fromkeys(organization_corporate_entity_ids))
edges: list[KnowledgeGraphEdgeSpec] = []
for person_id in unique_person_ids:
@@ -224,6 +247,44 @@ def knowledge_graph_edges_for_post(
)
)
+ for team_id in unique_team_ids:
+ edges.append(
+ KnowledgeGraphEdgeSpec(
+ source_node_type_code=NODE_TEAM,
+ source_node_id=team_id,
+ target_node_type_code=NODE_POST,
+ target_node_id=post_id,
+ edge_type_code=EDGE_MENTION_TEAM,
+ )
+ )
+
+ seen_team_affiliations: set[tuple[str, str]] = set()
+ for team_id, corporate_entity_id in team_corporate_entity_ids:
+ pair = (team_id, corporate_entity_id)
+ if pair in seen_team_affiliations:
+ continue
+ seen_team_affiliations.add(pair)
+ edges.append(
+ KnowledgeGraphEdgeSpec(
+ source_node_type_code=NODE_TEAM,
+ source_node_id=team_id,
+ target_node_type_code=NODE_CORPORATE_ENTITY,
+ target_node_id=corporate_entity_id,
+ edge_type_code=EDGE_TEAM_AFFILIATION,
+ )
+ )
+
+ for corporate_entity_id in unique_organization_ids:
+ edges.append(
+ KnowledgeGraphEdgeSpec(
+ source_node_type_code=NODE_CORPORATE_ENTITY,
+ source_node_id=corporate_entity_id,
+ target_node_type_code=NODE_POST,
+ target_node_id=post_id,
+ edge_type_code=EDGE_MENTION_ORGANIZATION,
+ )
+ )
+
return edges
diff --git a/lineageweave/organization_name_resolution.py b/lineageweave/organization_name_resolution.py
new file mode 100644
index 000000000..69b64d156
--- /dev/null
+++ b/lineageweave/organization_name_resolution.py
@@ -0,0 +1,196 @@
+"""Resolves an abbreviated or slang organization name (e.g. "AGP") to
+its full canonical name using LLM context, then cross-verifies the
+proposed name against external web search before it is trusted --
+:mod:`lineageweave.corporate_hierarchy_resolution`'s character-similarity
+matching cannot bridge this gap on its own: an initialism/acronym shares
+almost no substring with its expansion, so no similarity threshold
+recovers it. This module runs first, so its output feeds
+``resolve_corporate_entity`` a name with a real chance of matching, not
+instead of it.
+
+Grounded in SKOS (Miles & Bechhofer, 2009): ``skos:prefLabel`` (a
+resource's single preferred/canonical label) and ``skos:altLabel`` (an
+alternative label -- exactly the abbreviation/synonym case) are the
+standard vocabulary for this raw-name/canonical-name pair. See
+docs/adr/0008-organization-abbreviation-resolution.md.
+
+Same pluggable-client, never-fake-a-missing-channel discipline as every
+other channel in this package -- and, specifically, an LLM's proposed
+canonical name is never trusted on its own: it is only usable once
+:mod:`lineageweave.relation_verification`'s external-search check
+corroborates it, reusing that module's client rather than duplicating a
+second web-search integration.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Protocol
+
+from .http_client import post_json
+from .relation_verification import (
+ STATUS_PENDING,
+ RelationVerificationClient,
+)
+
+
+@dataclass(frozen=True)
+class OrganizationNameResolution:
+ """One raw name's resolution outcome, ready to persist to
+ ``organization_name_resolution``.
+
+ Attributes:
+ raw_organization_name: the abbreviated/slang name as mentioned
+ in the source text (``skos:altLabel``).
+ resolved_organization_name: the LLM's proposed full/canonical
+ name (``skos:prefLabel``).
+ verification_status_code: ``relation_verification_status``
+ lookup code -- whether external search corroborated the
+ resolved name, reusing the same category and semantics
+ :mod:`lineageweave.relation_verification` already defines.
+ verification_evidence_url: the corroborating search result's
+ URL, or ``None`` when uncorroborated or verification itself
+ was unavailable.
+ """
+
+ raw_organization_name: str
+ resolved_organization_name: str
+ verification_status_code: str
+ verification_evidence_url: str | None
+
+
+class OrganizationNameResolutionClient(Protocol):
+ """Proposes a full/canonical name for an abbreviated organization mention."""
+
+ available: bool
+
+ def resolve(self, raw_name: str, context_text: str) -> str | None:
+ """Return the proposed canonical name, or ``None`` when the
+ model cannot determine one from the given context.
+
+ Implementations must raise if the call itself fails (network
+ error, malformed response) -- a failed call is not the same
+ outcome as "the model looked and found nothing to propose."
+ Protocol stubs raise ``NotImplementedError`` so a no-op body is
+ never treated as a successful empty result.
+ """
+ raise NotImplementedError
+
+
+class NullOrganizationNameResolutionClient:
+ """No LLM orchestrator configured -- name resolution is unavailable."""
+
+ available = False
+
+ def resolve(self, raw_name: str, context_text: str) -> str | None:
+ raise RuntimeError(
+ "NullOrganizationNameResolutionClient cannot resolve; check .available first"
+ )
+
+
+_RESOLUTION_PROMPT_TEMPLATE = """\
+The text below mentions an organization by the short/abbreviated name
+"{raw_name}" (this may be a Korean-style contraction, an initialism, or
+another kind of shorthand -- e.g. "AGP" is a synthetic contraction
+for "Aurora Grid Power").
+
+Using ONLY what the text itself supports (do not guess from the
+abbreviation's letters/syllables alone if the text gives no supporting
+context), determine the organization's full, real-world name.
+
+Reply with ONLY the full organization name on a single line, in its
+most natural real-world form. If the text gives you no way to determine
+the full name with real confidence, reply with exactly: UNKNOWN
+
+Text: {context}
+"""
+
+
+def parse_resolution_response(content: str) -> str | None:
+ """Parses the LLM's reply into a proposed canonical name, or `None`
+ when it declined (``UNKNOWN``) or replied with nothing usable.
+
+ A one-line reply is the contract; only the first line is trusted --
+ a multi-line reply means the model did not follow instructions, and
+ trusting the wrong line would risk persisting prose as a name.
+ """
+ stripped = content.strip()
+ if not stripped or stripped.upper() == "UNKNOWN":
+ return None
+ first_line = stripped.splitlines()[0].strip()
+ if not first_line or first_line.upper() == "UNKNOWN":
+ return None
+ return first_line
+
+
+class ContextualOrchestratorOrganizationNameResolutionClient:
+ """Calls ``POST {base_url}/v1/chat/completions`` with ``mode="route"``."""
+
+ available = True
+
+ def __init__(
+ self, base_url: str, api_key: str, *, reasoning_effort: str = "medium", timeout: float = 30.0
+ ) -> None:
+ self._base_url = base_url.rstrip("/")
+ self._api_key = api_key
+ self._reasoning_effort = reasoning_effort
+ self._timeout = timeout
+
+ def resolve(self, raw_name: str, context_text: str) -> str | None:
+ prompt = _RESOLUTION_PROMPT_TEMPLATE.format(raw_name=raw_name, context=context_text)
+ body = post_json(
+ f"{self._base_url}/v1/chat/completions",
+ {
+ "messages": [{"role": "user", "content": prompt}],
+ "mode": "route",
+ "reasoning_effort": self._reasoning_effort,
+ },
+ headers={"authorization": f"Bearer {self._api_key}"},
+ timeout=self._timeout,
+ )
+ content = body["choices"][0]["message"]["content"]
+ return parse_resolution_response(content)
+
+
+def resolve_and_verify_organization_name(
+ raw_name: str,
+ context_text: str,
+ resolution_client: OrganizationNameResolutionClient,
+ verification_client: RelationVerificationClient,
+) -> OrganizationNameResolution | None:
+ """Runs the full resolve-then-verify pipeline for one raw name.
+
+ Returns ``None`` when resolution is unavailable, the model proposed
+ nothing, or it proposed back the same string it was given (not a
+ real resolution) -- the caller keeps using the raw name as-is in
+ every one of these cases, the same missing-vs-negative discipline
+ every other channel in this package follows. A verified result's
+ ``verification_status_code`` is only ever ``verify_corroborated`` /
+ ``verify_uncorroborated`` (real search ran) or ``verify_pending``
+ (search itself is unavailable, not that it ran and found nothing) --
+ never fabricated.
+ """
+ if not resolution_client.available:
+ return None
+ candidate = resolution_client.resolve(raw_name, context_text)
+ if candidate is None:
+ return None
+ resolved_name = candidate.strip()
+ if not resolved_name or resolved_name == raw_name.strip():
+ return None
+
+ if not verification_client.available:
+ return OrganizationNameResolution(
+ raw_organization_name=raw_name,
+ resolved_organization_name=resolved_name,
+ verification_status_code=STATUS_PENDING,
+ verification_evidence_url=None,
+ )
+
+ result = verification_client.verify(resolved_name, raw_name)
+ return OrganizationNameResolution(
+ raw_organization_name=raw_name,
+ resolved_organization_name=resolved_name,
+ verification_status_code=result.status_code,
+ verification_evidence_url=result.evidence_url,
+ )
diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py
index eac051fda..6b3207a38 100644
--- a/lineageweave/post_summary.py
+++ b/lineageweave/post_summary.py
@@ -14,7 +14,20 @@
bullet, not a summary sentence.
- **R&R (roles & responsibilities)**: semantic role labeling (Gildea &
Jurafsky, 2002) -- who did what, framed as an agent/action/responsibility
- triple per person named in the post, not prose.
+ triple per named actor in the post, not prose. The actor is not always
+ a person: business correspondence routinely names an organization
+ as the acting party ("당사" [our company], "Demo Corp"), not an
+ individual. Modeling every actor as a person loses this distinction
+ and makes an organization's affiliation-less name look like an
+ unresolved person. Grounded in W3C PROV-O (Lebo, Sahoo, & McGuinness,
+ 2013): ``prov:Agent`` is the general acting-party class, with
+ ``prov:Person`` and ``prov:Organization`` as its two recognized
+ subclasses -- the same distinction ``keyman_extraction``'s two-sided
+ (our-side/counterparty) person model already keeps for *people*, one
+ level up. A person actor also gets an inferred
+ ``affiliated_organization_name`` where the text supports it: a bare
+ person name without who they work for is hard to place in the same
+ way an unresolved organization name is.
Same pluggable-client, never-fake-a-missing-channel discipline as every
other Phase 2/3 channel: :class:`NullPostSummaryClient` makes the channel
@@ -30,13 +43,51 @@
from .http_client import post_json
+# common_lookup_value category "prov_agent_type" -- PROV-O's prov:Person /
+# prov:Organization for the micro/macro cases, plus a meso-level third
+# case this repo's own real data needed: a named sub-unit of a company
+# ("설계팀" / "design team"), which is neither a person nor the company
+# itself. Grounded in the W3C Organization Ontology's org:OrganizationalUnit
+# (Reynolds, 2014), not invented -- see docs/adr/0007-team-actor-type.md.
+ACTOR_TYPE_PERSON = "prov_person"
+ACTOR_TYPE_ORGANIZATION = "prov_organization"
+ACTOR_TYPE_TEAM = "prov_team"
+_VALID_ACTOR_TYPE_CODES = frozenset({ACTOR_TYPE_PERSON, ACTOR_TYPE_ORGANIZATION, ACTOR_TYPE_TEAM})
+
@dataclass(frozen=True)
class RoleResponsibility:
- """One person's role/responsibility as derived from the post text."""
+ """One actor's role/responsibility as derived from the post text.
+
+ Attributes:
+ actor_name: the person's or organization's name as named in the
+ text.
+ responsibility: what they are responsible for or did.
+ actor_type_code: ``ACTOR_TYPE_PERSON``, ``ACTOR_TYPE_ORGANIZATION``,
+ or ``ACTOR_TYPE_TEAM`` (PROV-O ``prov:Person`` /
+ ``prov:Organization``, or the meso-level
+ ``org:OrganizationalUnit`` for a named sub-unit like "설계팀")
+ -- which this actor actually is, not assumed to be a person.
+ affiliated_organization_name: for a person OR team actor, the
+ organization the text says or implies they belong to, when
+ the text supports it; ``None`` when the text gives no
+ affiliation to infer, or for an organization actor (its own
+ name already answers "which organization"). A team actor
+ without this is an unplaced team -- the text should usually
+ support it since a team is always someone's team.
+ """
- person_name: str
+ actor_name: str
responsibility: str
+ actor_type_code: str = ACTOR_TYPE_PERSON
+ affiliated_organization_name: str | None = None
+
+ def __post_init__(self) -> None:
+ if self.actor_type_code not in _VALID_ACTOR_TYPE_CODES:
+ raise ValueError(
+ f"actor_type_code must be one of {sorted(_VALID_ACTOR_TYPE_CODES)}, "
+ f"got {self.actor_type_code!r}"
+ )
@dataclass(frozen=True)
@@ -81,16 +132,34 @@ def summarize(self, post_title: str, post_body: str) -> PostSummary:
2. A list of key events: discrete, datable occurrences mentioned in the
post (e.g. "a bid was submitted", "a delivery date was confirmed"),
each as a short phrase.
-3. A list of roles & responsibilities: for each named person in the post,
- one short phrase describing what they are responsible for or did,
- according to the text.
+3. A list of roles & responsibilities: for each named actor in the post
+ -- a person, an organization acting in its own name (e.g. "당사"
+ [our company], "Demo Corp"), OR a named team/department inside an
+ organization (e.g. "설계팀" [design team], "Sales Team") -- one short
+ phrase describing what they are responsible for or did, according to
+ the text. Do not force an organization's name into a person slot, and
+ do not force a team's name into an organization slot: a team is a
+ sub-unit of a company, not the company itself -- decide which of the
+ three each actor is, and say which.
+ When the actor is a person and the text names or clearly implies who
+ they work for, also give that organization's name -- a bare person
+ name without their employer is hard to place. When the actor is a
+ team, also give the organization it belongs to (a team is always part
+ of some company, even when the text only names the team, e.g. a
+ Korean company's internal 설계팀 -- infer the parent company from
+ context when the text supports it).
Reply with ONLY a JSON object (no markdown fences, no prose) with exactly
these fields:
"korean_summary": string
"key_events": array of strings
- "roles_and_responsibilities": array of objects, each with
- "person_name" and "responsibility" string fields
+ "roles_and_responsibilities": array of objects, each with:
+ "actor_name": string
+ "responsibility": string
+ "actor_type": exactly "person", "organization", or "team"
+ "affiliated_organization_name": string, or null when the actor is an
+ organization, or when the text gives no affiliation to infer for a
+ person or team actor
Post title: {title}
Post body: {body}
@@ -134,15 +203,35 @@ def parse_summary_response(content: str) -> PostSummary | None:
for entry in rr_raw:
if not isinstance(entry, dict):
continue
- name = entry.get("person_name")
+ name = entry.get("actor_name")
responsibility = entry.get("responsibility")
+ actor_type_raw = entry.get("actor_type")
+ if actor_type_raw == "organization":
+ actor_type_code = ACTOR_TYPE_ORGANIZATION
+ elif actor_type_raw == "team":
+ actor_type_code = ACTOR_TYPE_TEAM
+ else:
+ actor_type_code = ACTOR_TYPE_PERSON
+ affiliation_raw = entry.get("affiliated_organization_name")
+ affiliated_organization_name = (
+ affiliation_raw.strip()
+ if isinstance(affiliation_raw, str) and affiliation_raw.strip()
+ else None
+ )
if (
isinstance(name, str)
and name.strip()
and isinstance(responsibility, str)
and responsibility.strip()
):
- roles.append(RoleResponsibility(person_name=name.strip(), responsibility=responsibility.strip()))
+ roles.append(
+ RoleResponsibility(
+ actor_name=name.strip(),
+ responsibility=responsibility.strip(),
+ actor_type_code=actor_type_code,
+ affiliated_organization_name=affiliated_organization_name,
+ )
+ )
return PostSummary(
korean_summary=korean_summary.strip(),
diff --git a/lineageweave/prov_o.py b/lineageweave/prov_o.py
new file mode 100644
index 000000000..57d7472f6
--- /dev/null
+++ b/lineageweave/prov_o.py
@@ -0,0 +1,862 @@
+"""Standards-complete W3C PROV-O relation registry and graph runtime.
+
+The module implements every class and every object/datatype property in the
+PROV-O Recommendation's normative cross-reference. It deliberately keeps
+LineageWeave's product-specific knowledge graph separate: PROV-O needs
+literal-valued properties and qualified influence resources, neither of
+which can be represented faithfully by the existing binary UUID edge table.
+
+Consumers may assert the compact, unqualified form, the qualified form, or
+both. :class:`ProvGraph` materializes the Recommendation's property
+hierarchy, declared inverses, symmetry, and the rule that a qualified form
+implies its corresponding unqualified relation. Appendix B inverse names
+are accepted as import aliases by reversing the assertion into the preferred
+PROV-O direction.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Final, Iterable, Literal as TypingLiteral, Mapping, cast
+
+from rdflib import Graph, Literal, Namespace, URIRef
+from rdflib.namespace import RDF, XSD
+
+PROV: Final = Namespace("http://www.w3.org/ns/prov#")
+_PROPERTY_KIND = TypingLiteral["object", "datatype"]
+
+
+class ProvValidationError(ValueError):
+ """Raised when an assertion violates a PROV-O domain, range, or shape."""
+
+
+def _snake_case(local_name: str) -> str:
+ """Convert a PROV-O camel-case local name to stable lower snake case."""
+ first_pass = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", local_name)
+ return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", first_pass).lower()
+
+
+def class_code(local_name: str) -> str:
+ """Relational code for one PROV-O class, e.g. ``prov_entity``."""
+ return f"prov_{_snake_case(local_name)}"
+
+
+def relation_code(local_name: str) -> str:
+ """Relational code for one PROV-O property, e.g. ``prov_used``."""
+ return f"prov_{_snake_case(local_name)}"
+
+
+@dataclass(frozen=True)
+class ProvClassSpec:
+ """One normative PROV-O class and its direct superclass names."""
+
+ local_name: str
+ superclasses: tuple[str, ...] = ()
+
+ @property
+ def iri(self) -> str:
+ """Absolute W3C IRI for the class."""
+ return str(PROV[self.local_name])
+
+ @property
+ def code(self) -> str:
+ """Stable multiword snake-case relational code."""
+ return class_code(self.local_name)
+
+
+@dataclass(frozen=True)
+class ProvRelationSpec:
+ """One normative PROV-O object or datatype property."""
+
+ local_name: str
+ property_kind: _PROPERTY_KIND
+ domains: tuple[str, ...]
+ ranges: tuple[str, ...] = ()
+ datatype_iri: str | None = None
+ superproperties: tuple[str, ...] = ()
+ defined_inverse: str | None = None
+ symmetric: bool = False
+
+ @property
+ def iri(self) -> str:
+ """Absolute W3C IRI for the property."""
+ return str(PROV[self.local_name])
+
+ @property
+ def code(self) -> str:
+ """Stable multiword snake-case relational code."""
+ return relation_code(self.local_name)
+
+
+@dataclass(frozen=True)
+class ProvQualificationSpec:
+ """Normative mapping from a binary relation to its qualified pattern."""
+
+ unqualified_relation: str
+ qualification_relation: str
+ influence_class: str
+ influencer_relation: str
+
+
+@dataclass(frozen=True)
+class ProvInverseSpec:
+ """Appendix B recommended inverse name for one object property.
+
+ ``defined_relation`` names a normative PROV-O property when the inverse
+ is itself part of the 50-term relation registry. Otherwise the name is
+ reserved for interoperable import/export but is not asserted as a new
+ ontology property by this implementation.
+ """
+
+ relation: str
+ inverse_local_name: str
+ defined_relation: str | None = None
+
+ @property
+ def inverse_iri(self) -> str:
+ """Absolute reserved inverse IRI in the PROV namespace."""
+ return str(PROV[self.inverse_local_name])
+
+
+# ---------------------------------------------------------------------------
+# Normative class registry (30 terms)
+# ---------------------------------------------------------------------------
+
+
+def _class(local_name: str, *superclasses: str) -> ProvClassSpec:
+ return ProvClassSpec(local_name, tuple(superclasses))
+
+
+PROV_CLASSES: Final[Mapping[str, ProvClassSpec]] = {
+ spec.local_name: spec
+ for spec in (
+ _class("Entity"),
+ _class("Activity"),
+ _class("Agent"),
+ _class("Collection", "Entity"),
+ _class("EmptyCollection", "Collection"),
+ _class("Bundle", "Entity"),
+ _class("Person", "Agent"),
+ _class("SoftwareAgent", "Agent"),
+ _class("Organization", "Agent"),
+ _class("Location"),
+ _class("Influence"),
+ _class("EntityInfluence", "Influence"),
+ _class("Usage", "InstantaneousEvent", "EntityInfluence"),
+ _class("Start", "InstantaneousEvent", "EntityInfluence"),
+ _class("End", "InstantaneousEvent", "EntityInfluence"),
+ _class("Derivation", "EntityInfluence"),
+ _class("PrimarySource", "Derivation"),
+ _class("Quotation", "Derivation"),
+ _class("Revision", "Derivation"),
+ _class("ActivityInfluence", "Influence"),
+ _class("Generation", "InstantaneousEvent", "ActivityInfluence"),
+ _class("Communication", "ActivityInfluence"),
+ _class("Invalidation", "InstantaneousEvent", "ActivityInfluence"),
+ _class("AgentInfluence", "Influence"),
+ _class("Attribution", "AgentInfluence"),
+ _class("Association", "AgentInfluence"),
+ _class("Plan", "Entity"),
+ _class("Delegation", "AgentInfluence"),
+ _class("InstantaneousEvent"),
+ _class("Role"),
+ )
+}
+
+
+# ---------------------------------------------------------------------------
+# Normative property registry (50 terms)
+# ---------------------------------------------------------------------------
+
+
+def _object(
+ local_name: str,
+ domains: tuple[str, ...],
+ ranges: tuple[str, ...],
+ *,
+ superproperties: tuple[str, ...] = (),
+ defined_inverse: str | None = None,
+ symmetric: bool = False,
+) -> ProvRelationSpec:
+ return ProvRelationSpec(
+ local_name=local_name,
+ property_kind="object",
+ domains=domains,
+ ranges=ranges,
+ superproperties=superproperties,
+ defined_inverse=defined_inverse,
+ symmetric=symmetric,
+ )
+
+
+def _datatype(
+ local_name: str,
+ domains: tuple[str, ...],
+ *,
+ datatype_iri: str | None,
+) -> ProvRelationSpec:
+ return ProvRelationSpec(
+ local_name=local_name,
+ property_kind="datatype",
+ domains=domains,
+ datatype_iri=datatype_iri,
+ )
+
+
+_RESOURCE_UNION = ("Entity", "Activity", "Agent")
+
+PROV_RELATIONS: Final[Mapping[str, ProvRelationSpec]] = {
+ spec.local_name: spec
+ for spec in (
+ # Starting-point properties.
+ _object(
+ "wasGeneratedBy",
+ ("Entity",),
+ ("Activity",),
+ superproperties=("wasInfluencedBy",),
+ defined_inverse="generated",
+ ),
+ _object(
+ "wasDerivedFrom",
+ ("Entity",),
+ ("Entity",),
+ superproperties=("wasInfluencedBy",),
+ ),
+ _object(
+ "wasAttributedTo",
+ ("Entity",),
+ ("Agent",),
+ superproperties=("wasInfluencedBy",),
+ ),
+ _datatype("startedAtTime", ("Activity",), datatype_iri=str(XSD.dateTime)),
+ _object("used", ("Activity",), ("Entity",), superproperties=("wasInfluencedBy",)),
+ _object(
+ "wasInformedBy",
+ ("Activity",),
+ ("Activity",),
+ superproperties=("wasInfluencedBy",),
+ ),
+ _datatype("endedAtTime", ("Activity",), datatype_iri=str(XSD.dateTime)),
+ _object(
+ "wasAssociatedWith",
+ ("Activity",),
+ ("Agent",),
+ superproperties=("wasInfluencedBy",),
+ ),
+ _object(
+ "actedOnBehalfOf",
+ ("Agent",),
+ ("Agent",),
+ superproperties=("wasInfluencedBy",),
+ ),
+ # Expanded properties.
+ _object(
+ "alternateOf",
+ ("Entity",),
+ ("Entity",),
+ defined_inverse="alternateOf",
+ symmetric=True,
+ ),
+ _object(
+ "specializationOf",
+ ("Entity",),
+ ("Entity",),
+ superproperties=("alternateOf",),
+ ),
+ _datatype("generatedAtTime", ("Entity",), datatype_iri=str(XSD.dateTime)),
+ _object(
+ "hadPrimarySource",
+ ("Entity",),
+ ("Entity",),
+ superproperties=("wasDerivedFrom",),
+ ),
+ _datatype("value", ("Entity",), datatype_iri=None),
+ _object(
+ "wasQuotedFrom",
+ ("Entity",),
+ ("Entity",),
+ superproperties=("wasDerivedFrom",),
+ ),
+ _object(
+ "wasRevisionOf",
+ ("Entity",),
+ ("Entity",),
+ superproperties=("wasDerivedFrom",),
+ ),
+ _datatype("invalidatedAtTime", ("Entity",), datatype_iri=str(XSD.dateTime)),
+ _object(
+ "wasInvalidatedBy",
+ ("Entity",),
+ ("Activity",),
+ superproperties=("wasInfluencedBy",),
+ defined_inverse="invalidated",
+ ),
+ _object(
+ "hadMember",
+ ("Collection",),
+ ("Entity",),
+ superproperties=("wasInfluencedBy",),
+ ),
+ _object(
+ "wasStartedBy",
+ ("Activity",),
+ ("Entity",),
+ superproperties=("wasInfluencedBy",),
+ ),
+ _object(
+ "wasEndedBy",
+ ("Activity",),
+ ("Entity",),
+ superproperties=("wasInfluencedBy",),
+ ),
+ _object(
+ "invalidated",
+ ("Activity",),
+ ("Entity",),
+ superproperties=("influenced",),
+ defined_inverse="wasInvalidatedBy",
+ ),
+ _object(
+ "influenced",
+ _RESOURCE_UNION,
+ _RESOURCE_UNION,
+ defined_inverse="wasInfluencedBy",
+ ),
+ _object(
+ "atLocation",
+ ("Activity", "Agent", "Entity", "InstantaneousEvent"),
+ ("Location",),
+ ),
+ _object(
+ "generated",
+ ("Activity",),
+ ("Entity",),
+ superproperties=("influenced",),
+ defined_inverse="wasGeneratedBy",
+ ),
+ # Qualified properties.
+ _object(
+ "wasInfluencedBy",
+ _RESOURCE_UNION,
+ _RESOURCE_UNION,
+ defined_inverse="influenced",
+ ),
+ _object("qualifiedInfluence", _RESOURCE_UNION, ("Influence",)),
+ _object(
+ "qualifiedGeneration",
+ ("Entity",),
+ ("Generation",),
+ superproperties=("qualifiedInfluence",),
+ ),
+ _object(
+ "qualifiedDerivation",
+ ("Entity",),
+ ("Derivation",),
+ superproperties=("qualifiedInfluence",),
+ ),
+ _object(
+ "qualifiedPrimarySource",
+ ("Entity",),
+ ("PrimarySource",),
+ superproperties=("qualifiedInfluence",),
+ ),
+ _object(
+ "qualifiedQuotation",
+ ("Entity",),
+ ("Quotation",),
+ superproperties=("qualifiedInfluence",),
+ ),
+ _object(
+ "qualifiedRevision",
+ ("Entity",),
+ ("Revision",),
+ superproperties=("qualifiedInfluence",),
+ ),
+ _object(
+ "qualifiedAttribution",
+ ("Entity",),
+ ("Attribution",),
+ superproperties=("qualifiedInfluence",),
+ ),
+ _object(
+ "qualifiedInvalidation",
+ ("Entity",),
+ ("Invalidation",),
+ superproperties=("qualifiedInfluence",),
+ ),
+ _object(
+ "qualifiedStart",
+ ("Activity",),
+ ("Start",),
+ superproperties=("qualifiedInfluence",),
+ ),
+ _object(
+ "qualifiedUsage",
+ ("Activity",),
+ ("Usage",),
+ superproperties=("qualifiedInfluence",),
+ ),
+ _object(
+ "qualifiedCommunication",
+ ("Activity",),
+ ("Communication",),
+ superproperties=("qualifiedInfluence",),
+ ),
+ _object(
+ "qualifiedAssociation",
+ ("Activity",),
+ ("Association",),
+ superproperties=("qualifiedInfluence",),
+ ),
+ _object(
+ "qualifiedEnd",
+ ("Activity",),
+ ("End",),
+ superproperties=("qualifiedInfluence",),
+ ),
+ _object(
+ "qualifiedDelegation",
+ ("Agent",),
+ ("Delegation",),
+ superproperties=("qualifiedInfluence",),
+ ),
+ _object("influencer", ("Influence",), _RESOURCE_UNION),
+ _object(
+ "entity",
+ ("EntityInfluence",),
+ ("Entity",),
+ superproperties=("influencer",),
+ ),
+ _object("hadUsage", ("Derivation",), ("Usage",)),
+ _object("hadGeneration", ("Derivation",), ("Generation",)),
+ _object(
+ "activity",
+ ("ActivityInfluence",),
+ ("Activity",),
+ superproperties=("influencer",),
+ ),
+ _object(
+ "agent",
+ ("AgentInfluence",),
+ ("Agent",),
+ superproperties=("influencer",),
+ ),
+ _object("hadPlan", ("Association",), ("Plan",)),
+ _object("hadActivity", ("Delegation", "Derivation", "End", "Start"), ("Activity",)),
+ _datatype("atTime", ("InstantaneousEvent",), datatype_iri=str(XSD.dateTime)),
+ _object("hadRole", ("Association", "InstantaneousEvent"), ("Role",)),
+ )
+}
+
+
+# ---------------------------------------------------------------------------
+# Normative qualification tables (Tables 2 and 3)
+# ---------------------------------------------------------------------------
+
+PROV_QUALIFICATIONS: Final[tuple[ProvQualificationSpec, ...]] = (
+ ProvQualificationSpec("wasGeneratedBy", "qualifiedGeneration", "Generation", "activity"),
+ ProvQualificationSpec("wasDerivedFrom", "qualifiedDerivation", "Derivation", "entity"),
+ ProvQualificationSpec("wasAttributedTo", "qualifiedAttribution", "Attribution", "agent"),
+ ProvQualificationSpec("used", "qualifiedUsage", "Usage", "entity"),
+ ProvQualificationSpec("wasInformedBy", "qualifiedCommunication", "Communication", "activity"),
+ ProvQualificationSpec("wasAssociatedWith", "qualifiedAssociation", "Association", "agent"),
+ ProvQualificationSpec("actedOnBehalfOf", "qualifiedDelegation", "Delegation", "agent"),
+ ProvQualificationSpec("wasInfluencedBy", "qualifiedInfluence", "Influence", "influencer"),
+ ProvQualificationSpec("hadPrimarySource", "qualifiedPrimarySource", "PrimarySource", "entity"),
+ ProvQualificationSpec("wasQuotedFrom", "qualifiedQuotation", "Quotation", "entity"),
+ ProvQualificationSpec("wasRevisionOf", "qualifiedRevision", "Revision", "entity"),
+ ProvQualificationSpec("wasInvalidatedBy", "qualifiedInvalidation", "Invalidation", "activity"),
+ ProvQualificationSpec("wasStartedBy", "qualifiedStart", "Start", "entity"),
+ ProvQualificationSpec("wasEndedBy", "qualifiedEnd", "End", "entity"),
+)
+
+
+# ---------------------------------------------------------------------------
+# Appendix B inverse-name registry (all 44 object properties)
+# ---------------------------------------------------------------------------
+
+_INVERSE_NAME_ROWS = {
+ "actedOnBehalfOf": "hadDelegate",
+ "activity": "activityOfInfluence",
+ "agent": "agentOfInfluence",
+ "alternateOf": "alternateOf",
+ "atLocation": "locationOf",
+ "entity": "entityOfInfluence",
+ "generated": "wasGeneratedBy",
+ "hadActivity": "wasActivityOfInfluence",
+ "hadGeneration": "generatedAsDerivation",
+ "hadMember": "wasMemberOf",
+ "hadPlan": "wasPlanOf",
+ "hadPrimarySource": "wasPrimarySourceOf",
+ "hadRole": "wasRoleIn",
+ "hadUsage": "wasUsedInDerivation",
+ "influenced": "wasInfluencedBy",
+ "influencer": "hadInfluence",
+ "invalidated": "wasInvalidatedBy",
+ "qualifiedAssociation": "qualifiedAssociationOf",
+ "qualifiedAttribution": "qualifiedAttributionOf",
+ "qualifiedCommunication": "qualifiedCommunicationOf",
+ "qualifiedDelegation": "qualifiedDelegationOf",
+ "qualifiedDerivation": "qualifiedDerivationOf",
+ "qualifiedEnd": "qualifiedEndOf",
+ "qualifiedGeneration": "qualifiedGenerationOf",
+ "qualifiedInfluence": "qualifiedInfluenceOf",
+ "qualifiedInvalidation": "qualifiedInvalidationOf",
+ "qualifiedPrimarySource": "qualifiedSourceOf",
+ "qualifiedQuotation": "qualifiedQuotationOf",
+ "qualifiedRevision": "revisedEntity",
+ "qualifiedStart": "qualifiedStartOf",
+ "qualifiedUsage": "qualifiedUsingActivity",
+ "specializationOf": "generalizationOf",
+ "used": "wasUsedBy",
+ "wasAssociatedWith": "wasAssociateFor",
+ "wasAttributedTo": "contributed",
+ "wasDerivedFrom": "hadDerivation",
+ "wasEndedBy": "ended",
+ "wasGeneratedBy": "generated",
+ "wasInfluencedBy": "influenced",
+ "wasInformedBy": "informed",
+ "wasInvalidatedBy": "invalidated",
+ "wasQuotedFrom": "quotedAs",
+ "wasRevisionOf": "hadRevision",
+ "wasStartedBy": "started",
+}
+
+PROV_RECOMMENDED_INVERSES: Final[Mapping[str, ProvInverseSpec]] = {
+ relation: ProvInverseSpec(
+ relation=relation,
+ inverse_local_name=inverse_name,
+ defined_relation=inverse_name if inverse_name in PROV_RELATIONS else None,
+ )
+ for relation, inverse_name in _INVERSE_NAME_ROWS.items()
+}
+
+# Non-standard-but-reserved aliases are safe to normalize because canonical
+# PROV-O names always win when the same local name is itself a real property.
+_INVERSE_ALIAS_TO_CANONICAL: Final[Mapping[str, str]] = {
+ spec.inverse_local_name: relation
+ for relation, spec in PROV_RECOMMENDED_INVERSES.items()
+ if spec.inverse_local_name not in PROV_RELATIONS
+}
+
+
+@dataclass(frozen=True)
+class ProvLiteral:
+ """RDF literal used as the object of a PROV-O datatype property."""
+
+ lexical_value: str
+ datatype_iri: str | None = None
+ language_tag: str | None = None
+
+ def __post_init__(self) -> None:
+ if self.datatype_iri and self.language_tag:
+ raise ProvValidationError("a literal cannot have both datatype_iri and language_tag")
+ if self.language_tag and not re.fullmatch(r"[A-Za-z]+(?:-[A-Za-z0-9]+)*", self.language_tag):
+ raise ProvValidationError("language_tag must be a valid BCP 47-style tag")
+
+ @classmethod
+ def datetime(cls, value: datetime) -> "ProvLiteral":
+ """Create a timezone-aware ``xsd:dateTime`` literal."""
+ if value.tzinfo is None or value.utcoffset() is None:
+ raise ProvValidationError("PROV-O dateTime values must be timezone-aware")
+ return cls(value.isoformat(), datatype_iri=str(XSD.dateTime))
+
+ def to_rdflib(self) -> Literal:
+ """Convert to an rdflib literal without changing lexical form."""
+ return Literal(
+ self.lexical_value,
+ datatype=URIRef(self.datatype_iri) if self.datatype_iri else None,
+ lang=self.language_tag,
+ )
+
+
+@dataclass(frozen=True)
+class ProvAssertion:
+ """One canonical PROV-O assertion with exactly one object kind."""
+
+ subject_iri: str
+ relation: str
+ object_resource_iri: str | None = None
+ object_literal: ProvLiteral | None = None
+
+ def __post_init__(self) -> None:
+ if (self.object_resource_iri is None) == (self.object_literal is None):
+ raise ProvValidationError(
+ "a provenance assertion must have exactly one resource or literal object"
+ )
+
+ @classmethod
+ def resource(cls, subject_iri: str, relation: str, object_iri: str) -> "ProvAssertion":
+ """Construct a resource-to-resource assertion."""
+ return cls(subject_iri, relation, object_resource_iri=object_iri)
+
+ @classmethod
+ def literal(
+ cls, subject_iri: str, relation: str, object_literal: ProvLiteral
+ ) -> "ProvAssertion":
+ """Construct a resource-to-literal assertion."""
+ return cls(subject_iri, relation, object_literal=object_literal)
+
+
+class ProvGraph:
+ """Validated in-memory PROV-O graph with deterministic entailment.
+
+ Resource IRIs are explicitly typed. Assertions may use a local PROV
+ name, ``prov:`` compact name, full PROV IRI, or an Appendix B reserved
+ inverse name. Reserved inverse names are rewritten into the preferred
+ PROV-O direction at insertion time.
+ """
+
+ def __init__(self) -> None:
+ self._resource_types: dict[str, set[str]] = {}
+ self._explicit_assertions: set[ProvAssertion] = set()
+
+ @property
+ def resource_types(self) -> Mapping[str, frozenset[str]]:
+ """Read-only snapshot of explicitly assigned resource types."""
+ return {iri: frozenset(types) for iri, types in self._resource_types.items()}
+
+ @property
+ def explicit_assertions(self) -> frozenset[ProvAssertion]:
+ """Assertions supplied by callers after inverse-alias normalization."""
+ return frozenset(self._explicit_assertions)
+
+ def add_resource(self, resource_iri: str, *class_names: str) -> None:
+ """Declare one resource and one or more normative PROV-O types."""
+ if not resource_iri:
+ raise ProvValidationError("resource_iri is required")
+ if not class_names:
+ raise ProvValidationError("at least one PROV-O class is required")
+ normalized = {self._normalize_class_name(name) for name in class_names}
+ self._resource_types.setdefault(resource_iri, set()).update(normalized)
+
+ def add_assertion(
+ self,
+ subject_iri: str,
+ relation: str,
+ object_value: str | ProvLiteral,
+ ) -> ProvAssertion:
+ """Validate, canonicalize, and store one PROV-O assertion."""
+ relation_name, reverse = self._normalize_relation_name(relation)
+ if reverse:
+ if isinstance(object_value, ProvLiteral):
+ raise ProvValidationError("an inverse object-property alias cannot reverse a literal")
+ subject_iri, object_value = object_value, subject_iri
+
+ spec = PROV_RELATIONS[relation_name]
+ self._validate_subject(subject_iri, spec)
+ if spec.property_kind == "object":
+ if isinstance(object_value, ProvLiteral):
+ raise ProvValidationError(f"{relation_name} requires a resource object")
+ self._validate_resource_object(object_value, spec)
+ assertion = ProvAssertion.resource(subject_iri, relation_name, object_value)
+ else:
+ if isinstance(object_value, str):
+ raise ProvValidationError(f"{relation_name} requires a literal object")
+ self._validate_literal_object(object_value, spec)
+ assertion = ProvAssertion.literal(subject_iri, relation_name, object_value)
+ self._explicit_assertions.add(assertion)
+ return assertion
+
+ def materialized_assertions(self) -> frozenset[ProvAssertion]:
+ """Return explicit assertions plus deterministic PROV-O entailments.
+
+ Materialization includes transitive superproperty closure, declared
+ standard inverses, ``alternateOf`` symmetry, all fourteen
+ qualified-to-unqualified mappings, and the four direct time
+ shortcuts defined by qualified Generation/Invalidation/Start/End.
+ """
+ assertions = set(self._explicit_assertions)
+ changed = True
+ while changed:
+ changed = False
+ additions: set[ProvAssertion] = set()
+
+ for assertion in assertions:
+ relation_spec = PROV_RELATIONS[assertion.relation]
+ for superproperty in relation_spec.superproperties:
+ additions.add(self._same_object(assertion, superproperty))
+ if assertion.object_resource_iri is not None:
+ if relation_spec.defined_inverse is not None:
+ additions.add(
+ ProvAssertion.resource(
+ assertion.object_resource_iri,
+ relation_spec.defined_inverse,
+ assertion.subject_iri,
+ )
+ )
+ if relation_spec.symmetric:
+ additions.add(
+ ProvAssertion.resource(
+ assertion.object_resource_iri,
+ assertion.relation,
+ assertion.subject_iri,
+ )
+ )
+
+ by_relation: dict[str, list[ProvAssertion]] = {}
+ for assertion in assertions | additions:
+ by_relation.setdefault(assertion.relation, []).append(assertion)
+
+ for qualification in PROV_QUALIFICATIONS:
+ qualified_edges = by_relation.get(qualification.qualification_relation, [])
+ influencer_edges = by_relation.get(qualification.influencer_relation, [])
+ influencers_by_node: dict[str, list[str]] = {}
+ for edge in influencer_edges:
+ influencer_iri = cast(str, edge.object_resource_iri)
+ influencers_by_node.setdefault(edge.subject_iri, []).append(influencer_iri)
+ for edge in qualified_edges:
+ qualified_node = cast(str, edge.object_resource_iri)
+ for influencer_iri in influencers_by_node.get(qualified_node, []):
+ additions.add(
+ ProvAssertion.resource(
+ edge.subject_iri,
+ qualification.unqualified_relation,
+ influencer_iri,
+ )
+ )
+
+ # Direct time properties are shorthand for atTime on the
+ # corresponding qualified instantaneous event.
+ for qualified_relation, direct_time_relation in (
+ ("qualifiedGeneration", "generatedAtTime"),
+ ("qualifiedInvalidation", "invalidatedAtTime"),
+ ("qualifiedStart", "startedAtTime"),
+ ("qualifiedEnd", "endedAtTime"),
+ ):
+ event_times: dict[str, list[ProvLiteral]] = {}
+ for at_time in by_relation.get("atTime", []):
+ literal = cast(ProvLiteral, at_time.object_literal)
+ event_times.setdefault(at_time.subject_iri, []).append(literal)
+ for edge in by_relation.get(qualified_relation, []):
+ event_iri = cast(str, edge.object_resource_iri)
+ for literal in event_times.get(event_iri, []):
+ additions.add(
+ ProvAssertion.literal(edge.subject_iri, direct_time_relation, literal)
+ )
+
+ new_assertions = additions - assertions
+ if new_assertions:
+ assertions.update(new_assertions)
+ changed = True
+
+ return frozenset(assertions)
+
+ def to_rdflib(self, *, materialize: bool = False) -> Graph:
+ """Serialize explicit or materialized content to an rdflib graph."""
+ graph = Graph()
+ graph.bind("prov", PROV)
+ for resource_iri, types in self._resource_types.items():
+ for class_name in sorted(types):
+ graph.add((URIRef(resource_iri), RDF.type, PROV[class_name]))
+ assertions: Iterable[ProvAssertion]
+ assertions = self.materialized_assertions() if materialize else self.explicit_assertions
+ for assertion in assertions:
+ subject = URIRef(assertion.subject_iri)
+ predicate = PROV[assertion.relation]
+ if assertion.object_resource_iri is not None:
+ object_node = URIRef(assertion.object_resource_iri)
+ else:
+ assert assertion.object_literal is not None
+ object_node = assertion.object_literal.to_rdflib()
+ graph.add((subject, predicate, object_node))
+ return graph
+
+ @staticmethod
+ def _same_object(assertion: ProvAssertion, relation: str) -> ProvAssertion:
+ """Copy an object-property assertion under one of its superproperties."""
+ return ProvAssertion.resource(
+ assertion.subject_iri, relation, cast(str, assertion.object_resource_iri)
+ )
+
+ @staticmethod
+ def _normalize_class_name(class_name: str) -> str:
+ local_name = _local_name(class_name)
+ if local_name not in PROV_CLASSES:
+ raise ProvValidationError(f"unknown PROV-O class {class_name!r}")
+ return local_name
+
+ @staticmethod
+ def _normalize_relation_name(relation: str) -> tuple[str, bool]:
+ local_name = _local_name(relation)
+ if local_name in PROV_RELATIONS:
+ return local_name, False
+ canonical = _INVERSE_ALIAS_TO_CANONICAL.get(local_name)
+ if canonical is None:
+ raise ProvValidationError(f"unknown PROV-O relation {relation!r}")
+ return canonical, True
+
+ def _validate_subject(self, subject_iri: str, spec: ProvRelationSpec) -> None:
+ actual_types = self._resource_types.get(subject_iri)
+ if actual_types is None:
+ raise ProvValidationError(f"subject resource {subject_iri!r} has not been declared")
+ if not _matches_any_class(actual_types, spec.domains):
+ expected = " or ".join(spec.domains)
+ raise ProvValidationError(
+ f"subject {subject_iri!r} of {spec.local_name} must be {expected}"
+ )
+
+ def _validate_resource_object(self, object_iri: str, spec: ProvRelationSpec) -> None:
+ actual_types = self._resource_types.get(object_iri)
+ if actual_types is None:
+ raise ProvValidationError(f"object resource {object_iri!r} has not been declared")
+ if not _matches_any_class(actual_types, spec.ranges):
+ expected = " or ".join(spec.ranges)
+ raise ProvValidationError(
+ f"object {object_iri!r} of {spec.local_name} must be {expected}"
+ )
+
+ @staticmethod
+ def _validate_literal_object(literal: ProvLiteral, spec: ProvRelationSpec) -> None:
+ if spec.datatype_iri is not None and literal.datatype_iri != spec.datatype_iri:
+ raise ProvValidationError(
+ f"{spec.local_name} requires datatype {spec.datatype_iri}, "
+ f"got {literal.datatype_iri!r}"
+ )
+
+
+def _local_name(value: str) -> str:
+ """Return the local name from a local, compact, or absolute PROV IRI."""
+ if value.startswith(str(PROV)):
+ return value[len(str(PROV)) :]
+ if value.startswith("prov:"):
+ return value[5:]
+ return value
+
+
+def _class_ancestors(class_name: str) -> frozenset[str]:
+ """Return a class and every transitive superclass without duplicates."""
+ ancestors = {class_name}
+ pending = [class_name]
+ while pending:
+ current = pending.pop()
+ unseen = set(PROV_CLASSES[current].superclasses) - ancestors
+ ancestors.update(unseen)
+ pending.extend(unseen)
+ return frozenset(ancestors)
+
+
+def _matches_any_class(actual_types: Iterable[str], expected_types: Iterable[str]) -> bool:
+ expected = set(expected_types)
+ return any(bool(_class_ancestors(actual) & expected) for actual in actual_types)
+
+
+__all__ = [
+ "PROV",
+ "PROV_CLASSES",
+ "PROV_QUALIFICATIONS",
+ "PROV_RELATIONS",
+ "PROV_RECOMMENDED_INVERSES",
+ "ProvAssertion",
+ "ProvClassSpec",
+ "ProvGraph",
+ "ProvInverseSpec",
+ "ProvLiteral",
+ "ProvQualificationSpec",
+ "ProvRelationSpec",
+ "ProvValidationError",
+ "class_code",
+ "relation_code",
+]
diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql
index 6a2676a4c..a94b05a0c 100644
--- a/migrations/0001_initial_schema.sql
+++ b/migrations/0001_initial_schema.sql
@@ -43,7 +43,8 @@ comment on table common_lookup_value is
'Every ENUM-like value in this schema (voc_type, post_visibility, '
'entity_relationship_type, person_side, edge_type, node_type, '
'ticket_status, permission, corporate_entity_level, '
- 'relation_verification_status, evaluation_criterion) lives here once. '
+ 'relation_verification_status, evaluation_criterion, prov_agent_type) '
+ 'lives here once. '
'lookup_code is unique across all categories -- see the unique(lookup_code) comment.';
-- ---------------------------------------------------------------------
@@ -211,11 +212,17 @@ create table post_summary_event (
primary key (post_id, event_ordinal)
);
+-- actor_type_code: R&R Ontology, see migrations/0012_role_responsibility_agent_type.sql
+-- and ADR 0006 -- a named actor is not always a person (an organization
+-- can act in its own name, e.g. "당사," "Demo Corp"), so this is not folded
+-- into person_name's own meaning.
create table post_summary_role (
post_id uuid not null references post_summary_result (post_id) on delete cascade,
- person_name text not null,
+ actor_name text not null,
responsibility text not null,
- primary key (post_id, person_name)
+ actor_type_code text not null default 'prov_person' references common_lookup_value (lookup_code),
+ affiliated_organization_name text,
+ primary key (post_id, actor_name)
);
-- Persisted in-popup Q&A. Seed writes a synthetic exchange so
@@ -326,10 +333,16 @@ create table report_item_information (
-- ---------------------------------------------------------------------
-- Cataloged people mentioned in posts (Keyman). Named cataloged_person,
-- not person, so every table name is two or more snake_case words.
+-- last_known_job_title: the disambiguation signal migrations/0013 adds.
+-- Lives here, not only on person_affiliation.role_title, because a
+-- stated title ("our legal counsel, Sam Okonkwo") is real same-name
+-- evidence even when the text names no specific organization to attach
+-- a person_affiliation row to.
create table cataloged_person (
person_id uuid primary key default uuid_generate_v4(),
person_name text not null,
person_side_code text not null references common_lookup_value (lookup_code),
+ last_known_job_title text,
created_at timestamptz not null default now()
);
@@ -343,6 +356,9 @@ create table person_affiliation (
);
create index person_affiliation_person_idx on person_affiliation (person_id);
+create index person_affiliation_corporate_entity_idx
+ on person_affiliation (affiliated_corporate_entity_id)
+ where affiliated_corporate_entity_id is not null;
create table post_person_mention (
post_id uuid not null references source_post (post_id),
@@ -351,6 +367,59 @@ create table post_person_mention (
primary key (post_id, person_id)
);
+create table post_summary_person_mention (
+ post_id uuid not null references source_post (post_id) on delete cascade,
+ person_id uuid not null references cataloged_person (person_id),
+ primary key (post_id, person_id)
+);
+
+-- Read-side union only. The two writable tables retain the evidence source:
+-- post_person_mention is Keyman extraction; post_summary_person_mention is R&R.
+create view combined_post_person_mention as
+ select post_id, person_id from post_person_mention
+ union
+ select post_id, person_id from post_summary_person_mention;
+
+-- ---------------------------------------------------------------------
+-- Cross-post identity resolution for R&R actors (ADR 0009/0007): a
+-- team named across two posts (e.g. 설계팀) must resolve to the same
+-- row, the same way cataloged_person/corporate_entity already give
+-- persons/organizations a shared identity across posts.
+-- ---------------------------------------------------------------------
+create table cataloged_team (
+ team_id uuid primary key default uuid_generate_v4(),
+ team_name text not null,
+ affiliated_organization_name text,
+ affiliated_corporate_entity_id uuid references corporate_entity (corporate_entity_id),
+ created_at timestamptz not null default now(),
+ unique nulls not distinct (team_name, affiliated_organization_name)
+);
+
+create index cataloged_team_corporate_entity_idx
+ on cataloged_team (affiliated_corporate_entity_id)
+ where affiliated_corporate_entity_id is not null;
+
+create table post_team_mention (
+ post_id uuid not null references source_post (post_id),
+ team_id uuid not null references cataloged_team (team_id),
+ primary key (post_id, team_id)
+);
+
+create table post_organization_mention (
+ post_id uuid not null references source_post (post_id),
+ corporate_entity_id uuid not null references corporate_entity (corporate_entity_id),
+ primary key (post_id, corporate_entity_id)
+);
+
+-- ADR 0019: store the resolved catalog id on the role row itself.
+-- corporate_entity.entity_name is not unique, and mention tables are
+-- post-scoped, so reconstructing identity by name is not 3NF.
+alter table post_summary_role
+ add column cataloged_team_id uuid references cataloged_team (team_id);
+alter table post_summary_role
+ add column cataloged_corporate_entity_id uuid
+ references corporate_entity (corporate_entity_id);
+
-- ---------------------------------------------------------------------
-- Knowledge graph: person/company/post nodes, typed edges. The type
-- codes (which kind of node, which kind of edge) are real enums and DO
@@ -369,12 +438,81 @@ create table knowledge_graph_edge (
target_node_id uuid not null,
edge_type_code text not null references common_lookup_value (lookup_code),
edge_weight numeric not null default 1.0,
- created_at timestamptz not null default now()
+ created_at timestamptz not null default now(),
+ constraint knowledge_graph_edge_identity_uq unique (
+ source_node_type_code, source_node_id,
+ target_node_type_code, target_node_id,
+ edge_type_code
+ )
);
create index knowledge_graph_edge_source_idx on knowledge_graph_edge (source_node_type_code, source_node_id);
create index knowledge_graph_edge_target_idx on knowledge_graph_edge (target_node_type_code, target_node_id);
+create table if not exists knowledge_graph_edge_evidence (
+ knowledge_graph_edge_id uuid not null
+ references knowledge_graph_edge (knowledge_graph_edge_id) on delete cascade,
+ evidence_post_id uuid not null references source_post (post_id) on delete cascade,
+ primary key (knowledge_graph_edge_id, evidence_post_id)
+);
+
+create index if not exists knowledge_graph_edge_evidence_post_idx
+ on knowledge_graph_edge_evidence (evidence_post_id, knowledge_graph_edge_id);
+
+create or replace function register_knowledge_graph_edge_evidence()
+returns trigger
+language plpgsql
+as $$
+begin
+ if new.edge_type_code in (
+ 'edge_mention',
+ 'edge_mention_team',
+ 'edge_mention_organization'
+ ) and new.target_node_type_code = 'node_post' then
+ insert into knowledge_graph_edge_evidence
+ (knowledge_graph_edge_id, evidence_post_id)
+ values (new.knowledge_graph_edge_id, new.target_node_id)
+ on conflict do nothing;
+ elsif new.edge_type_code = 'edge_co_mention' then
+ insert into knowledge_graph_edge_evidence
+ (knowledge_graph_edge_id, evidence_post_id)
+ select distinct new.knowledge_graph_edge_id, left_mention.post_id
+ from combined_post_person_mention left_mention
+ join combined_post_person_mention right_mention
+ on right_mention.post_id = left_mention.post_id
+ where left_mention.person_id = new.source_node_id
+ and right_mention.person_id = new.target_node_id
+ on conflict do nothing;
+ elsif new.edge_type_code = 'edge_affiliation' then
+ insert into knowledge_graph_edge_evidence
+ (knowledge_graph_edge_id, evidence_post_id)
+ select distinct new.knowledge_graph_edge_id, mention.post_id
+ from combined_post_person_mention mention
+ join person_affiliation affiliation
+ on affiliation.person_id = mention.person_id
+ where mention.person_id = new.source_node_id
+ and affiliation.affiliated_corporate_entity_id = new.target_node_id
+ on conflict do nothing;
+ elsif new.edge_type_code = 'edge_team_affiliation' then
+ insert into knowledge_graph_edge_evidence
+ (knowledge_graph_edge_id, evidence_post_id)
+ select distinct new.knowledge_graph_edge_id, mention.post_id
+ from post_team_mention mention
+ join cataloged_team team on team.team_id = mention.team_id
+ where mention.team_id = new.source_node_id
+ and team.affiliated_corporate_entity_id = new.target_node_id
+ on conflict do nothing;
+ end if;
+ return new;
+end
+$$;
+
+drop trigger if exists knowledge_graph_edge_evidence_register
+ on knowledge_graph_edge;
+create trigger knowledge_graph_edge_evidence_register
+after insert or update on knowledge_graph_edge
+for each row execute function register_knowledge_graph_edge_evidence();
+
-- ---------------------------------------------------------------------
-- Issue tickets tied to a post.
-- ---------------------------------------------------------------------
@@ -412,4 +550,26 @@ create table post_lineage_edge (
primary key (parent_post_id, child_post_id)
);
+-- ---------------------------------------------------------------------
+-- Caches an abbreviated/slang organization name's LLM-inferred
+-- canonical name plus external search cross-verification (ADR 0008),
+-- e.g. "AGP" -> "Aurora Grid Power" -- keyed by the raw name so the same
+-- abbreviation across many posts is resolved once, not re-queried
+-- every mention. Grounded in SKOS skos:altLabel/skos:prefLabel (see
+-- docs/ontology/lineageweave-kg.ttl); verification_status_code reuses
+-- relation_verification_status (migration 0004) rather than a
+-- near-duplicate category -- a resolved name is corroborated/
+-- uncorroborated the same way a classified relationship is.
+-- ---------------------------------------------------------------------
+create table organization_name_resolution (
+ raw_organization_name text primary key,
+ resolved_organization_name text not null,
+ verification_status_code text not null references common_lookup_value (lookup_code),
+ verification_evidence_url text,
+ resolved_at timestamptz not null default now()
+);
+
+comment on table organization_name_resolution is
+ 'Caches LLM-proposed canonical names for abbreviated/slang organization mentions (e.g. AGP -> Aurora Grid Power), cross-verified via external search before being trusted.';
+
commit;
diff --git a/migrations/0012_role_responsibility_agent_type.sql b/migrations/0012_role_responsibility_agent_type.sql
new file mode 100644
index 000000000..3bcea3499
--- /dev/null
+++ b/migrations/0012_role_responsibility_agent_type.sql
@@ -0,0 +1,33 @@
+-- Roles & responsibilities' named actor is not always a person --
+-- business correspondence routinely names an organization acting in its
+-- own name ("당사" [our company], "Demo Corp"), not an
+-- individual. Adds a PROV-O-grounded person/organization distinction
+-- (see ADR 0006) plus an inferred affiliated-organization name for
+-- person actors. The rename below (person_name -> actor_name) preserves
+-- every existing row's data -- a plain RENAME COLUMN, not a drop/recreate
+-- -- since a volume that already ran the pre-0006 0001 has real rows
+-- under the old name.
+
+insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values
+ ('prov_agent_type', 'prov_person', 'Person', 0),
+ ('prov_agent_type', 'prov_organization', 'Organization', 1)
+on conflict (lookup_code) do nothing;
+
+do $$
+begin
+ if exists (
+ select 1 from information_schema.columns
+ where table_schema = 'public'
+ and table_name = 'post_summary_role'
+ and column_name = 'person_name'
+ ) then
+ alter table post_summary_role rename column person_name to actor_name;
+ end if;
+end $$;
+
+alter table post_summary_role
+ add column if not exists actor_type_code text not null default 'prov_person'
+ references common_lookup_value (lookup_code);
+
+alter table post_summary_role
+ add column if not exists affiliated_organization_name text;
diff --git a/migrations/0013_person_job_title.sql b/migrations/0013_person_job_title.sql
new file mode 100644
index 000000000..5904a6e0b
--- /dev/null
+++ b/migrations/0013_person_job_title.sql
@@ -0,0 +1,11 @@
+-- Same-name-people disambiguation signal: a stated job title/position is
+-- real evidence a same person_name+person_side_code match is NOT the
+-- same real individual. Lives on cataloged_person itself, not only
+-- person_affiliation.role_title, because a title is real disambiguation
+-- evidence even when the text names no specific organization to attach
+-- an affiliation row to (e.g. "our legal counsel, Sam Okonkwo").
+-- ADD COLUMN IF NOT EXISTS so a volume that already ran 0001 still
+-- upgrades.
+
+alter table cataloged_person
+ add column if not exists last_known_job_title text;
diff --git a/migrations/0014_role_responsibility_team_actor_type.sql b/migrations/0014_role_responsibility_team_actor_type.sql
new file mode 100644
index 000000000..e701aef3f
--- /dev/null
+++ b/migrations/0014_role_responsibility_team_actor_type.sql
@@ -0,0 +1,11 @@
+-- A third roles-and-responsibilities actor case real data surfaced:
+-- a named sub-unit of a company ("설계팀" [design team]) is meso-level --
+-- neither a person nor the company itself. Adds `prov_team` alongside
+-- `prov_person`/`prov_organization` (migration 0012); grounded in the
+-- W3C Organization Ontology's org:OrganizationalUnit (see ADR 0007),
+-- not PROV-O, which has no sub-organization concept. Purely additive:
+-- no existing row's actor_type_code changes.
+
+insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values
+ ('prov_agent_type', 'prov_team', 'Team', 2)
+on conflict (lookup_code) do nothing;
diff --git a/migrations/0015_organization_name_resolution.sql b/migrations/0015_organization_name_resolution.sql
new file mode 100644
index 000000000..7d18320ff
--- /dev/null
+++ b/migrations/0015_organization_name_resolution.sql
@@ -0,0 +1,18 @@
+-- Caches an abbreviated/slang organization name's LLM-inferred
+-- canonical name plus external search cross-verification (ADR 0008),
+-- e.g. "AGP" -> "Aurora Grid Power". corporate_hierarchy_resolution's
+-- character-similarity matching cannot bridge this gap (an initialism
+-- shares almost no substring with its expansion), so a genuine
+-- LLM-context + web-evidence step is needed instead. Keyed by the raw
+-- name so the same abbreviation across many posts is resolved once.
+
+create table if not exists organization_name_resolution (
+ raw_organization_name text primary key,
+ resolved_organization_name text not null,
+ verification_status_code text not null references common_lookup_value (lookup_code),
+ verification_evidence_url text,
+ resolved_at timestamptz not null default now()
+);
+
+comment on table organization_name_resolution is
+ 'Caches LLM-proposed canonical names for abbreviated/slang organization mentions (e.g. AGP -> Aurora Grid Power), cross-verified via external search before being trusted.';
diff --git a/migrations/0016_cross_post_actor_identity.sql b/migrations/0016_cross_post_actor_identity.sql
new file mode 100644
index 000000000..a20266994
--- /dev/null
+++ b/migrations/0016_cross_post_actor_identity.sql
@@ -0,0 +1,179 @@
+-- Cross-post identity resolution for R&R actors (ADR 0009). Extraction
+-- runs per-post, but the same team, person, or organization named
+-- across two different posts must resolve to the same catalog row --
+-- otherwise every extraction is an island and can never become a
+-- cross-post Knowledge Graph clue.
+--
+-- Teams (prov_team, ADR 0007) had no catalog at all until now, unlike
+-- persons (cataloged_person, already Keyman's identity catalog) and
+-- organizations (corporate_entity, already the corporate hierarchy
+-- catalog). This migration adds the missing team catalog and two
+-- mention join tables (post_team_mention, post_organization_mention)
+-- so knowledge_graph_edge writers can derive Team/Organization mention
+-- edges the same way they already derive Person mention edges from
+-- post_person_mention.
+
+create table if not exists cataloged_team (
+ team_id uuid primary key default uuid_generate_v4(),
+ team_name text not null,
+ affiliated_organization_name text,
+ affiliated_corporate_entity_id uuid references corporate_entity (corporate_entity_id),
+ created_at timestamptz not null default now(),
+ -- A team name alone rarely uniquely identifies it across a whole
+ -- product's real-world scope ("설계팀" exists at many companies);
+ -- the (name, org) pair almost always does. NULLS NOT DISTINCT makes
+ -- a missing affiliation participate in the same identity key, so
+ -- concurrent upserts of the same unplaced team return one row.
+ unique nulls not distinct (team_name, affiliated_organization_name)
+);
+
+create index if not exists cataloged_team_corporate_entity_idx
+ on cataloged_team (affiliated_corporate_entity_id)
+ where affiliated_corporate_entity_id is not null;
+
+create table if not exists post_team_mention (
+ post_id uuid not null references source_post (post_id),
+ team_id uuid not null references cataloged_team (team_id),
+ primary key (post_id, team_id)
+);
+
+create table if not exists post_organization_mention (
+ post_id uuid not null references source_post (post_id),
+ corporate_entity_id uuid not null references corporate_entity (corporate_entity_id),
+ primary key (post_id, corporate_entity_id)
+);
+
+insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values
+ ('corporate_entity_level', 'group', 'Group', 0),
+ ('corporate_entity_level', 'company', 'Company', 1),
+ ('corporate_entity_level', 'plant', 'Plant', 2),
+ ('node_type', 'node_team', 'Team', 3),
+ ('edge_type', 'edge_mention_team', 'Team mentioned in', 3),
+ ('edge_type', 'edge_team_affiliation', 'Team affiliated with', 4),
+ ('edge_type', 'edge_mention_organization', 'Organization mentioned in', 5)
+on conflict (lookup_code) do nothing;
+-- Keyman and R&R person mentions are independent replaceable evidence
+-- channels. The upgrade copies matching R&R actor names into
+-- post_summary_person_mention and leaves post_person_mention (including
+-- mention_context) untouched. combined_post_person_mention already unions
+-- both sources; deleting Keyman rows would drop mention_context and let a
+-- later persist_post_summary erase the only remaining person evidence.
+create table if not exists post_summary_person_mention (
+ post_id uuid not null references source_post (post_id) on delete cascade,
+ person_id uuid not null references cataloged_person (person_id),
+ primary key (post_id, person_id)
+ );
+
+ create or replace view combined_post_person_mention as
+ select post_id, person_id from post_person_mention
+ union
+ select post_id, person_id from post_summary_person_mention;
+
+ insert into post_summary_person_mention (post_id, person_id)
+ select distinct role.post_id, matched_person.person_id
+ from post_summary_role role
+ join lateral (
+ select person.person_id
+ from cataloged_person person
+ where person.person_name = role.actor_name
+ order by person.created_at, person.person_id
+ limit 1
+ ) matched_person on true
+ where role.actor_type_code = 'prov_person'
+ on conflict do nothing;
+
+ with ranked_edge as (
+ select knowledge_graph_edge_id,
+ row_number() over (
+ partition by source_node_type_code, source_node_id,
+ target_node_type_code, target_node_id,
+ edge_type_code
+ order by created_at, knowledge_graph_edge_id
+ ) as duplicate_rank
+ from knowledge_graph_edge
+ )
+ delete from knowledge_graph_edge edge_row
+ using ranked_edge duplicate
+ where edge_row.knowledge_graph_edge_id = duplicate.knowledge_graph_edge_id
+ and duplicate.duplicate_rank > 1;
+
+ create unique index if not exists knowledge_graph_edge_identity_uq
+ on knowledge_graph_edge (
+ source_node_type_code, source_node_id,
+ target_node_type_code, target_node_id,
+ edge_type_code
+ );
+
+ create table if not exists knowledge_graph_edge_evidence (
+ knowledge_graph_edge_id uuid not null
+ references knowledge_graph_edge (knowledge_graph_edge_id) on delete cascade,
+ evidence_post_id uuid not null references source_post (post_id) on delete cascade,
+ primary key (knowledge_graph_edge_id, evidence_post_id)
+);
+
+create index if not exists knowledge_graph_edge_evidence_post_idx
+ on knowledge_graph_edge_evidence (evidence_post_id, knowledge_graph_edge_id);
+
+create or replace function register_knowledge_graph_edge_evidence()
+returns trigger
+language plpgsql
+as $$
+begin
+ if new.edge_type_code in (
+ 'edge_mention',
+ 'edge_mention_team',
+ 'edge_mention_organization'
+ ) and new.target_node_type_code = 'node_post' then
+ insert into knowledge_graph_edge_evidence
+ (knowledge_graph_edge_id, evidence_post_id)
+ values (new.knowledge_graph_edge_id, new.target_node_id)
+ on conflict do nothing;
+ elsif new.edge_type_code = 'edge_co_mention' then
+ insert into knowledge_graph_edge_evidence
+ (knowledge_graph_edge_id, evidence_post_id)
+ select distinct new.knowledge_graph_edge_id, left_mention.post_id
+ from combined_post_person_mention left_mention
+ join combined_post_person_mention right_mention
+ on right_mention.post_id = left_mention.post_id
+ where left_mention.person_id = new.source_node_id
+ and right_mention.person_id = new.target_node_id
+ on conflict do nothing;
+ elsif new.edge_type_code = 'edge_affiliation' then
+ insert into knowledge_graph_edge_evidence
+ (knowledge_graph_edge_id, evidence_post_id)
+ select distinct new.knowledge_graph_edge_id, mention.post_id
+ from combined_post_person_mention mention
+ join person_affiliation affiliation
+ on affiliation.person_id = mention.person_id
+ where mention.person_id = new.source_node_id
+ and affiliation.affiliated_corporate_entity_id = new.target_node_id
+ on conflict do nothing;
+ elsif new.edge_type_code = 'edge_team_affiliation' then
+ insert into knowledge_graph_edge_evidence
+ (knowledge_graph_edge_id, evidence_post_id)
+ select distinct new.knowledge_graph_edge_id, mention.post_id
+ from post_team_mention mention
+ join cataloged_team team on team.team_id = mention.team_id
+ where mention.team_id = new.source_node_id
+ and team.affiliated_corporate_entity_id = new.target_node_id
+ on conflict do nothing;
+ end if;
+ return new;
+end
+$$;
+
+drop trigger if exists knowledge_graph_edge_evidence_register
+ on knowledge_graph_edge;
+create trigger knowledge_graph_edge_evidence_register
+after insert or update on knowledge_graph_edge
+for each row execute function register_knowledge_graph_edge_evidence();
+
+ -- Re-run the support trigger for every surviving legacy edge, then prune
+ -- rows that cannot be tied to current post evidence.
+ update knowledge_graph_edge set edge_weight = edge_weight;
+ delete from knowledge_graph_edge edge_row
+ where not exists (
+ select 1
+ from knowledge_graph_edge_evidence evidence
+ where evidence.knowledge_graph_edge_id = edge_row.knowledge_graph_edge_id
+ );
diff --git a/migrations/0017_prov_o_standard_relations.sql b/migrations/0017_prov_o_standard_relations.sql
new file mode 100644
index 000000000..46867acc1
--- /dev/null
+++ b/migrations/0017_prov_o_standard_relations.sql
@@ -0,0 +1,636 @@
+-- W3C PROV-O standards-complete provenance layer (ADR 0011).
+--
+-- Implements every one of the Recommendation's 30 classes and 50
+-- normative object/datatype properties, both qualification tables, the
+-- property/class hierarchies, and every Appendix B recommended inverse
+-- name. Runtime provenance data is stored separately from the product's
+-- compact knowledge_graph_edge table because PROV-O must represent
+-- literals and qualified Influence resources without flattening them.
+--
+-- All database objects use two-or-more-word snake_case and the catalog is
+-- normalized: class/property definitions, hierarchies, domains, ranges,
+-- qualification mappings, inverse names, resources, types, literals, and
+-- assertions each have one authoritative table.
+
+begin;
+
+create table if not exists provenance_class_definition (
+ class_code text primary key,
+ class_iri text not null unique,
+ class_local_name text not null unique,
+ class_label text not null
+);
+
+create table if not exists provenance_class_hierarchy (
+ child_class_code text not null references provenance_class_definition (class_code),
+ parent_class_code text not null references provenance_class_definition (class_code),
+ primary key (child_class_code, parent_class_code),
+ check (child_class_code <> parent_class_code)
+);
+
+create table if not exists provenance_relation_definition (
+ relation_code text primary key,
+ relation_iri text not null unique,
+ relation_local_name text not null unique,
+ relation_label text not null,
+ property_kind_code text not null check (property_kind_code in ('object', 'datatype')),
+ datatype_iri text,
+ symmetric_flag boolean not null default false,
+ check (property_kind_code = 'datatype' or datatype_iri is null)
+);
+
+create table if not exists provenance_relation_hierarchy (
+ child_relation_code text not null references provenance_relation_definition (relation_code),
+ parent_relation_code text not null references provenance_relation_definition (relation_code),
+ primary key (child_relation_code, parent_relation_code),
+ check (child_relation_code <> parent_relation_code)
+);
+
+create table if not exists provenance_relation_domain (
+ relation_code text not null references provenance_relation_definition (relation_code),
+ domain_class_code text not null references provenance_class_definition (class_code),
+ primary key (relation_code, domain_class_code)
+);
+
+create table if not exists provenance_relation_resource_range (
+ relation_code text not null references provenance_relation_definition (relation_code),
+ range_class_code text not null references provenance_class_definition (class_code),
+ primary key (relation_code, range_class_code)
+);
+
+create table if not exists provenance_qualification_definition (
+ unqualified_relation_code text primary key references provenance_relation_definition (relation_code),
+ qualification_relation_code text not null unique references provenance_relation_definition (relation_code),
+ influence_class_code text not null references provenance_class_definition (class_code),
+ influencer_relation_code text not null references provenance_relation_definition (relation_code)
+);
+
+create table if not exists provenance_inverse_definition (
+ relation_code text primary key references provenance_relation_definition (relation_code),
+ inverse_local_name text not null,
+ inverse_iri text not null,
+ inverse_relation_code text references provenance_relation_definition (relation_code),
+ inverse_kind_code text not null check (inverse_kind_code in ('defined', 'recommended')),
+ check (
+ (inverse_kind_code = 'defined' and inverse_relation_code is not null)
+ or (inverse_kind_code = 'recommended' and inverse_relation_code is null)
+ )
+);
+
+create table if not exists provenance_resource (
+ resource_id uuid primary key default uuid_generate_v4(),
+ resource_iri text not null unique,
+ resource_label text,
+ created_at timestamptz not null default now()
+);
+
+create table if not exists provenance_resource_type (
+ resource_id uuid not null references provenance_resource (resource_id) on delete cascade,
+ class_code text not null references provenance_class_definition (class_code),
+ primary key (resource_id, class_code)
+);
+
+create table if not exists provenance_literal_value (
+ literal_id uuid primary key default uuid_generate_v4(),
+ lexical_value text not null,
+ datatype_iri text,
+ language_tag text,
+ created_at timestamptz not null default now(),
+ check (datatype_iri is null or language_tag is null)
+);
+
+create table if not exists provenance_resource_binding (
+ resource_id uuid not null references provenance_resource (resource_id) on delete cascade,
+ node_type_code text not null references common_lookup_value (lookup_code),
+ node_id uuid not null,
+ primary key (resource_id, node_type_code, node_id),
+ unique (node_type_code, node_id)
+);
+
+create table if not exists provenance_assertion (
+ assertion_id uuid primary key default uuid_generate_v4(),
+ subject_resource_id uuid not null references provenance_resource (resource_id),
+ relation_code text not null references provenance_relation_definition (relation_code),
+ object_resource_id uuid references provenance_resource (resource_id),
+ object_literal_id uuid references provenance_literal_value (literal_id),
+ bundle_resource_id uuid references provenance_resource (resource_id),
+ created_at timestamptz not null default now(),
+ check (num_nonnulls(object_resource_id, object_literal_id) = 1)
+);
+
+create unique index if not exists provenance_assertion_resource_unique_idx
+ on provenance_assertion (subject_resource_id, relation_code, object_resource_id, bundle_resource_id)
+ nulls not distinct
+ where object_resource_id is not null;
+
+create unique index if not exists provenance_assertion_literal_unique_idx
+ on provenance_assertion (subject_resource_id, relation_code, object_literal_id, bundle_resource_id)
+ nulls not distinct
+ where object_literal_id is not null;
+
+create table if not exists provenance_assertion_derivation (
+ derived_assertion_id uuid not null references provenance_assertion (assertion_id) on delete cascade,
+ source_assertion_id uuid not null references provenance_assertion (assertion_id) on delete cascade,
+ primary key (derived_assertion_id, source_assertion_id),
+ check (derived_assertion_id <> source_assertion_id)
+);
+
+create or replace function validate_provenance_assertion_contract()
+returns trigger
+language plpgsql
+as $$
+declare
+ relation_kind text;
+ required_datatype text;
+ literal_datatype text;
+ literal_lexical text;
+begin
+ select property_kind_code, datatype_iri
+ into relation_kind, required_datatype
+ from provenance_relation_definition
+ where relation_code = new.relation_code;
+
+ if relation_kind = 'object' and new.object_resource_id is null then
+ raise exception 'PROV-O object property % requires object_resource_id', new.relation_code;
+ end if;
+ if relation_kind = 'datatype' and new.object_literal_id is null then
+ raise exception 'PROV-O datatype property % requires object_literal_id', new.relation_code;
+ end if;
+
+ if not exists (
+ with recursive subject_class (class_code) as (
+ select class_code
+ from provenance_resource_type
+ where resource_id = new.subject_resource_id
+ union
+ select hierarchy.parent_class_code
+ from subject_class
+ join provenance_class_hierarchy hierarchy
+ on hierarchy.child_class_code = subject_class.class_code
+ )
+ select 1
+ from subject_class
+ join provenance_relation_domain domain_rule
+ on domain_rule.domain_class_code = subject_class.class_code
+ where domain_rule.relation_code = new.relation_code
+ ) then
+ raise exception 'subject resource % violates PROV-O domain for %',
+ new.subject_resource_id, new.relation_code;
+ end if;
+
+ if relation_kind = 'object' and not exists (
+ with recursive object_class (class_code) as (
+ select class_code
+ from provenance_resource_type
+ where resource_id = new.object_resource_id
+ union
+ select hierarchy.parent_class_code
+ from object_class
+ join provenance_class_hierarchy hierarchy
+ on hierarchy.child_class_code = object_class.class_code
+ )
+ select 1
+ from object_class
+ join provenance_relation_resource_range range_rule
+ on range_rule.range_class_code = object_class.class_code
+ where range_rule.relation_code = new.relation_code
+ ) then
+ raise exception 'object resource % violates PROV-O range for %',
+ new.object_resource_id, new.relation_code;
+ end if;
+
+ if relation_kind = 'datatype' then
+ select datatype_iri, lexical_value
+ into literal_datatype, literal_lexical
+ from provenance_literal_value
+ where literal_id = new.object_literal_id;
+
+ if required_datatype is not null
+ and literal_datatype is distinct from required_datatype then
+ raise exception 'literal % violates datatype % for %',
+ new.object_literal_id, required_datatype, new.relation_code;
+ end if;
+
+ if required_datatype = 'http://www.w3.org/2001/XMLSchema#dateTime' then
+ -- XSD dateTime offsets are Z or ±hh:mm with a maximum of ±14:00.
+ if literal_lexical !~ (
+ '^[0-9]{4}-(0[1-9]|1[0-2])-'
+ '(0[1-9]|[12][0-9]|3[01])T'
+ '([01][0-9]|2[0-3]):[0-5][0-9]:'
+ '[0-5][0-9](\.[0-9]+)?'
+ '(Z|[+-]((0[0-9]|1[0-3]):[0-5][0-9]|14:00))$'
+ ) then
+ raise exception 'literal % violates lexical xsd:dateTime for %',
+ new.object_literal_id, new.relation_code;
+ end if;
+ begin
+ perform literal_lexical::timestamptz;
+ exception when others then
+ raise exception 'literal % violates lexical xsd:dateTime for %',
+ new.object_literal_id, new.relation_code;
+ end;
+ end if;
+ end if;
+
+ return new;
+end;
+$$;
+
+drop trigger if exists provenance_assertion_contract_trigger on provenance_assertion;
+create trigger provenance_assertion_contract_trigger
+before insert or update on provenance_assertion
+for each row execute function validate_provenance_assertion_contract();
+
+create or replace function protect_provenance_contract_reference()
+returns trigger
+language plpgsql
+as $$
+begin
+ if tg_table_name = 'provenance_resource_type' and exists (
+ select 1
+ from provenance_assertion
+ where subject_resource_id = (to_jsonb(old)->>'resource_id')::uuid
+ or object_resource_id = (to_jsonb(old)->>'resource_id')::uuid
+ ) then
+ raise exception 'referenced provenance resource types are immutable';
+ end if;
+
+ if tg_table_name = 'provenance_literal_value' and exists (
+ select 1
+ from provenance_assertion
+ where object_literal_id = (to_jsonb(old)->>'literal_id')::uuid
+ ) then
+ raise exception 'referenced provenance literal values are immutable';
+ end if;
+ if tg_op = 'UPDATE' then
+ return new;
+ end if;
+ return old;
+end;
+$$;
+
+drop trigger if exists provenance_resource_type_reference_trigger
+ on provenance_resource_type;
+create trigger provenance_resource_type_reference_trigger
+before update or delete on provenance_resource_type
+for each row execute function protect_provenance_contract_reference();
+
+drop trigger if exists provenance_literal_value_reference_trigger
+ on provenance_literal_value;
+create trigger provenance_literal_value_reference_trigger
+before update or delete on provenance_literal_value
+for each row execute function protect_provenance_contract_reference();
+
+insert into provenance_class_definition (class_code, class_iri, class_local_name, class_label) values
+ ('prov_entity', 'http://www.w3.org/ns/prov#Entity', 'Entity', 'Entity'),
+ ('prov_activity', 'http://www.w3.org/ns/prov#Activity', 'Activity', 'Activity'),
+ ('prov_agent', 'http://www.w3.org/ns/prov#Agent', 'Agent', 'Agent'),
+ ('prov_collection', 'http://www.w3.org/ns/prov#Collection', 'Collection', 'Collection'),
+ ('prov_empty_collection', 'http://www.w3.org/ns/prov#EmptyCollection', 'EmptyCollection', 'Empty Collection'),
+ ('prov_bundle', 'http://www.w3.org/ns/prov#Bundle', 'Bundle', 'Bundle'),
+ ('prov_person', 'http://www.w3.org/ns/prov#Person', 'Person', 'Person'),
+ ('prov_software_agent', 'http://www.w3.org/ns/prov#SoftwareAgent', 'SoftwareAgent', 'Software Agent'),
+ ('prov_organization', 'http://www.w3.org/ns/prov#Organization', 'Organization', 'Organization'),
+ ('prov_location', 'http://www.w3.org/ns/prov#Location', 'Location', 'Location'),
+ ('prov_influence', 'http://www.w3.org/ns/prov#Influence', 'Influence', 'Influence'),
+ ('prov_entity_influence', 'http://www.w3.org/ns/prov#EntityInfluence', 'EntityInfluence', 'Entity Influence'),
+ ('prov_usage', 'http://www.w3.org/ns/prov#Usage', 'Usage', 'Usage'),
+ ('prov_start', 'http://www.w3.org/ns/prov#Start', 'Start', 'Start'),
+ ('prov_end', 'http://www.w3.org/ns/prov#End', 'End', 'End'),
+ ('prov_derivation', 'http://www.w3.org/ns/prov#Derivation', 'Derivation', 'Derivation'),
+ ('prov_primary_source', 'http://www.w3.org/ns/prov#PrimarySource', 'PrimarySource', 'Primary Source'),
+ ('prov_quotation', 'http://www.w3.org/ns/prov#Quotation', 'Quotation', 'Quotation'),
+ ('prov_revision', 'http://www.w3.org/ns/prov#Revision', 'Revision', 'Revision'),
+ ('prov_activity_influence', 'http://www.w3.org/ns/prov#ActivityInfluence', 'ActivityInfluence', 'Activity Influence'),
+ ('prov_generation', 'http://www.w3.org/ns/prov#Generation', 'Generation', 'Generation'),
+ ('prov_communication', 'http://www.w3.org/ns/prov#Communication', 'Communication', 'Communication'),
+ ('prov_invalidation', 'http://www.w3.org/ns/prov#Invalidation', 'Invalidation', 'Invalidation'),
+ ('prov_agent_influence', 'http://www.w3.org/ns/prov#AgentInfluence', 'AgentInfluence', 'Agent Influence'),
+ ('prov_attribution', 'http://www.w3.org/ns/prov#Attribution', 'Attribution', 'Attribution'),
+ ('prov_association', 'http://www.w3.org/ns/prov#Association', 'Association', 'Association'),
+ ('prov_plan', 'http://www.w3.org/ns/prov#Plan', 'Plan', 'Plan'),
+ ('prov_delegation', 'http://www.w3.org/ns/prov#Delegation', 'Delegation', 'Delegation'),
+ ('prov_instantaneous_event', 'http://www.w3.org/ns/prov#InstantaneousEvent', 'InstantaneousEvent', 'Instantaneous Event'),
+ ('prov_role', 'http://www.w3.org/ns/prov#Role', 'Role', 'Role')
+on conflict (class_code) do update set
+ class_iri = excluded.class_iri,
+ class_local_name = excluded.class_local_name,
+ class_label = excluded.class_label;
+
+insert into provenance_class_hierarchy (child_class_code, parent_class_code) values
+ ('prov_collection', 'prov_entity'),
+ ('prov_empty_collection', 'prov_collection'),
+ ('prov_bundle', 'prov_entity'),
+ ('prov_person', 'prov_agent'),
+ ('prov_software_agent', 'prov_agent'),
+ ('prov_organization', 'prov_agent'),
+ ('prov_entity_influence', 'prov_influence'),
+ ('prov_usage', 'prov_instantaneous_event'),
+ ('prov_usage', 'prov_entity_influence'),
+ ('prov_start', 'prov_instantaneous_event'),
+ ('prov_start', 'prov_entity_influence'),
+ ('prov_end', 'prov_instantaneous_event'),
+ ('prov_end', 'prov_entity_influence'),
+ ('prov_derivation', 'prov_entity_influence'),
+ ('prov_primary_source', 'prov_derivation'),
+ ('prov_quotation', 'prov_derivation'),
+ ('prov_revision', 'prov_derivation'),
+ ('prov_activity_influence', 'prov_influence'),
+ ('prov_generation', 'prov_instantaneous_event'),
+ ('prov_generation', 'prov_activity_influence'),
+ ('prov_communication', 'prov_activity_influence'),
+ ('prov_invalidation', 'prov_instantaneous_event'),
+ ('prov_invalidation', 'prov_activity_influence'),
+ ('prov_agent_influence', 'prov_influence'),
+ ('prov_attribution', 'prov_agent_influence'),
+ ('prov_association', 'prov_agent_influence'),
+ ('prov_plan', 'prov_entity'),
+ ('prov_delegation', 'prov_agent_influence')
+on conflict do nothing;
+
+insert into provenance_relation_definition (relation_code, relation_iri, relation_local_name, relation_label, property_kind_code, datatype_iri, symmetric_flag) values
+ ('prov_was_generated_by', 'http://www.w3.org/ns/prov#wasGeneratedBy', 'wasGeneratedBy', 'Was Generated By', 'object', null, false),
+ ('prov_was_derived_from', 'http://www.w3.org/ns/prov#wasDerivedFrom', 'wasDerivedFrom', 'Was Derived From', 'object', null, false),
+ ('prov_was_attributed_to', 'http://www.w3.org/ns/prov#wasAttributedTo', 'wasAttributedTo', 'Was Attributed To', 'object', null, false),
+ ('prov_started_at_time', 'http://www.w3.org/ns/prov#startedAtTime', 'startedAtTime', 'Started At Time', 'datatype', 'http://www.w3.org/2001/XMLSchema#dateTime', false),
+ ('prov_used', 'http://www.w3.org/ns/prov#used', 'used', 'Used', 'object', null, false),
+ ('prov_was_informed_by', 'http://www.w3.org/ns/prov#wasInformedBy', 'wasInformedBy', 'Was Informed By', 'object', null, false),
+ ('prov_ended_at_time', 'http://www.w3.org/ns/prov#endedAtTime', 'endedAtTime', 'Ended At Time', 'datatype', 'http://www.w3.org/2001/XMLSchema#dateTime', false),
+ ('prov_was_associated_with', 'http://www.w3.org/ns/prov#wasAssociatedWith', 'wasAssociatedWith', 'Was Associated With', 'object', null, false),
+ ('prov_acted_on_behalf_of', 'http://www.w3.org/ns/prov#actedOnBehalfOf', 'actedOnBehalfOf', 'Acted On Behalf Of', 'object', null, false),
+ ('prov_alternate_of', 'http://www.w3.org/ns/prov#alternateOf', 'alternateOf', 'Alternate Of', 'object', null, true),
+ ('prov_specialization_of', 'http://www.w3.org/ns/prov#specializationOf', 'specializationOf', 'Specialization Of', 'object', null, false),
+ ('prov_generated_at_time', 'http://www.w3.org/ns/prov#generatedAtTime', 'generatedAtTime', 'Generated At Time', 'datatype', 'http://www.w3.org/2001/XMLSchema#dateTime', false),
+ ('prov_had_primary_source', 'http://www.w3.org/ns/prov#hadPrimarySource', 'hadPrimarySource', 'Had Primary Source', 'object', null, false),
+ ('prov_value', 'http://www.w3.org/ns/prov#value', 'value', 'Value', 'datatype', null, false),
+ ('prov_was_quoted_from', 'http://www.w3.org/ns/prov#wasQuotedFrom', 'wasQuotedFrom', 'Was Quoted From', 'object', null, false),
+ ('prov_was_revision_of', 'http://www.w3.org/ns/prov#wasRevisionOf', 'wasRevisionOf', 'Was Revision Of', 'object', null, false),
+ ('prov_invalidated_at_time', 'http://www.w3.org/ns/prov#invalidatedAtTime', 'invalidatedAtTime', 'Invalidated At Time', 'datatype', 'http://www.w3.org/2001/XMLSchema#dateTime', false),
+ ('prov_was_invalidated_by', 'http://www.w3.org/ns/prov#wasInvalidatedBy', 'wasInvalidatedBy', 'Was Invalidated By', 'object', null, false),
+ ('prov_had_member', 'http://www.w3.org/ns/prov#hadMember', 'hadMember', 'Had Member', 'object', null, false),
+ ('prov_was_started_by', 'http://www.w3.org/ns/prov#wasStartedBy', 'wasStartedBy', 'Was Started By', 'object', null, false),
+ ('prov_was_ended_by', 'http://www.w3.org/ns/prov#wasEndedBy', 'wasEndedBy', 'Was Ended By', 'object', null, false),
+ ('prov_invalidated', 'http://www.w3.org/ns/prov#invalidated', 'invalidated', 'Invalidated', 'object', null, false),
+ ('prov_influenced', 'http://www.w3.org/ns/prov#influenced', 'influenced', 'Influenced', 'object', null, false),
+ ('prov_at_location', 'http://www.w3.org/ns/prov#atLocation', 'atLocation', 'At Location', 'object', null, false),
+ ('prov_generated', 'http://www.w3.org/ns/prov#generated', 'generated', 'Generated', 'object', null, false),
+ ('prov_was_influenced_by', 'http://www.w3.org/ns/prov#wasInfluencedBy', 'wasInfluencedBy', 'Was Influenced By', 'object', null, false),
+ ('prov_qualified_influence', 'http://www.w3.org/ns/prov#qualifiedInfluence', 'qualifiedInfluence', 'Qualified Influence', 'object', null, false),
+ ('prov_qualified_generation', 'http://www.w3.org/ns/prov#qualifiedGeneration', 'qualifiedGeneration', 'Qualified Generation', 'object', null, false),
+ ('prov_qualified_derivation', 'http://www.w3.org/ns/prov#qualifiedDerivation', 'qualifiedDerivation', 'Qualified Derivation', 'object', null, false),
+ ('prov_qualified_primary_source', 'http://www.w3.org/ns/prov#qualifiedPrimarySource', 'qualifiedPrimarySource', 'Qualified Primary Source', 'object', null, false),
+ ('prov_qualified_quotation', 'http://www.w3.org/ns/prov#qualifiedQuotation', 'qualifiedQuotation', 'Qualified Quotation', 'object', null, false),
+ ('prov_qualified_revision', 'http://www.w3.org/ns/prov#qualifiedRevision', 'qualifiedRevision', 'Qualified Revision', 'object', null, false),
+ ('prov_qualified_attribution', 'http://www.w3.org/ns/prov#qualifiedAttribution', 'qualifiedAttribution', 'Qualified Attribution', 'object', null, false),
+ ('prov_qualified_invalidation', 'http://www.w3.org/ns/prov#qualifiedInvalidation', 'qualifiedInvalidation', 'Qualified Invalidation', 'object', null, false),
+ ('prov_qualified_start', 'http://www.w3.org/ns/prov#qualifiedStart', 'qualifiedStart', 'Qualified Start', 'object', null, false),
+ ('prov_qualified_usage', 'http://www.w3.org/ns/prov#qualifiedUsage', 'qualifiedUsage', 'Qualified Usage', 'object', null, false),
+ ('prov_qualified_communication', 'http://www.w3.org/ns/prov#qualifiedCommunication', 'qualifiedCommunication', 'Qualified Communication', 'object', null, false),
+ ('prov_qualified_association', 'http://www.w3.org/ns/prov#qualifiedAssociation', 'qualifiedAssociation', 'Qualified Association', 'object', null, false),
+ ('prov_qualified_end', 'http://www.w3.org/ns/prov#qualifiedEnd', 'qualifiedEnd', 'Qualified End', 'object', null, false),
+ ('prov_qualified_delegation', 'http://www.w3.org/ns/prov#qualifiedDelegation', 'qualifiedDelegation', 'Qualified Delegation', 'object', null, false),
+ ('prov_influencer', 'http://www.w3.org/ns/prov#influencer', 'influencer', 'Influencer', 'object', null, false),
+ ('prov_entity', 'http://www.w3.org/ns/prov#entity', 'entity', 'Entity', 'object', null, false),
+ ('prov_had_usage', 'http://www.w3.org/ns/prov#hadUsage', 'hadUsage', 'Had Usage', 'object', null, false),
+ ('prov_had_generation', 'http://www.w3.org/ns/prov#hadGeneration', 'hadGeneration', 'Had Generation', 'object', null, false),
+ ('prov_activity', 'http://www.w3.org/ns/prov#activity', 'activity', 'Activity', 'object', null, false),
+ ('prov_agent', 'http://www.w3.org/ns/prov#agent', 'agent', 'Agent', 'object', null, false),
+ ('prov_had_plan', 'http://www.w3.org/ns/prov#hadPlan', 'hadPlan', 'Had Plan', 'object', null, false),
+ ('prov_had_activity', 'http://www.w3.org/ns/prov#hadActivity', 'hadActivity', 'Had Activity', 'object', null, false),
+ ('prov_at_time', 'http://www.w3.org/ns/prov#atTime', 'atTime', 'At Time', 'datatype', 'http://www.w3.org/2001/XMLSchema#dateTime', false),
+ ('prov_had_role', 'http://www.w3.org/ns/prov#hadRole', 'hadRole', 'Had Role', 'object', null, false)
+on conflict (relation_code) do update set
+ relation_iri = excluded.relation_iri,
+ relation_local_name = excluded.relation_local_name,
+ relation_label = excluded.relation_label,
+ property_kind_code = excluded.property_kind_code,
+ datatype_iri = excluded.datatype_iri,
+ symmetric_flag = excluded.symmetric_flag;
+
+insert into provenance_relation_hierarchy (child_relation_code, parent_relation_code) values
+ ('prov_was_generated_by', 'prov_was_influenced_by'),
+ ('prov_was_derived_from', 'prov_was_influenced_by'),
+ ('prov_was_attributed_to', 'prov_was_influenced_by'),
+ ('prov_used', 'prov_was_influenced_by'),
+ ('prov_was_informed_by', 'prov_was_influenced_by'),
+ ('prov_was_associated_with', 'prov_was_influenced_by'),
+ ('prov_acted_on_behalf_of', 'prov_was_influenced_by'),
+ ('prov_specialization_of', 'prov_alternate_of'),
+ ('prov_had_primary_source', 'prov_was_derived_from'),
+ ('prov_was_quoted_from', 'prov_was_derived_from'),
+ ('prov_was_revision_of', 'prov_was_derived_from'),
+ ('prov_was_invalidated_by', 'prov_was_influenced_by'),
+ ('prov_had_member', 'prov_was_influenced_by'),
+ ('prov_was_started_by', 'prov_was_influenced_by'),
+ ('prov_was_ended_by', 'prov_was_influenced_by'),
+ ('prov_invalidated', 'prov_influenced'),
+ ('prov_generated', 'prov_influenced'),
+ ('prov_qualified_generation', 'prov_qualified_influence'),
+ ('prov_qualified_derivation', 'prov_qualified_influence'),
+ ('prov_qualified_primary_source', 'prov_qualified_influence'),
+ ('prov_qualified_quotation', 'prov_qualified_influence'),
+ ('prov_qualified_revision', 'prov_qualified_influence'),
+ ('prov_qualified_attribution', 'prov_qualified_influence'),
+ ('prov_qualified_invalidation', 'prov_qualified_influence'),
+ ('prov_qualified_start', 'prov_qualified_influence'),
+ ('prov_qualified_usage', 'prov_qualified_influence'),
+ ('prov_qualified_communication', 'prov_qualified_influence'),
+ ('prov_qualified_association', 'prov_qualified_influence'),
+ ('prov_qualified_end', 'prov_qualified_influence'),
+ ('prov_qualified_delegation', 'prov_qualified_influence'),
+ ('prov_entity', 'prov_influencer'),
+ ('prov_activity', 'prov_influencer'),
+ ('prov_agent', 'prov_influencer')
+on conflict do nothing;
+
+insert into provenance_relation_domain (relation_code, domain_class_code) values
+ ('prov_was_generated_by', 'prov_entity'),
+ ('prov_was_derived_from', 'prov_entity'),
+ ('prov_was_attributed_to', 'prov_entity'),
+ ('prov_started_at_time', 'prov_activity'),
+ ('prov_used', 'prov_activity'),
+ ('prov_was_informed_by', 'prov_activity'),
+ ('prov_ended_at_time', 'prov_activity'),
+ ('prov_was_associated_with', 'prov_activity'),
+ ('prov_acted_on_behalf_of', 'prov_agent'),
+ ('prov_alternate_of', 'prov_entity'),
+ ('prov_specialization_of', 'prov_entity'),
+ ('prov_generated_at_time', 'prov_entity'),
+ ('prov_had_primary_source', 'prov_entity'),
+ ('prov_value', 'prov_entity'),
+ ('prov_was_quoted_from', 'prov_entity'),
+ ('prov_was_revision_of', 'prov_entity'),
+ ('prov_invalidated_at_time', 'prov_entity'),
+ ('prov_was_invalidated_by', 'prov_entity'),
+ ('prov_had_member', 'prov_collection'),
+ ('prov_was_started_by', 'prov_activity'),
+ ('prov_was_ended_by', 'prov_activity'),
+ ('prov_invalidated', 'prov_activity'),
+ ('prov_influenced', 'prov_entity'),
+ ('prov_influenced', 'prov_activity'),
+ ('prov_influenced', 'prov_agent'),
+ ('prov_at_location', 'prov_activity'),
+ ('prov_at_location', 'prov_agent'),
+ ('prov_at_location', 'prov_entity'),
+ ('prov_at_location', 'prov_instantaneous_event'),
+ ('prov_generated', 'prov_activity'),
+ ('prov_was_influenced_by', 'prov_entity'),
+ ('prov_was_influenced_by', 'prov_activity'),
+ ('prov_was_influenced_by', 'prov_agent'),
+ ('prov_qualified_influence', 'prov_entity'),
+ ('prov_qualified_influence', 'prov_activity'),
+ ('prov_qualified_influence', 'prov_agent'),
+ ('prov_qualified_generation', 'prov_entity'),
+ ('prov_qualified_derivation', 'prov_entity'),
+ ('prov_qualified_primary_source', 'prov_entity'),
+ ('prov_qualified_quotation', 'prov_entity'),
+ ('prov_qualified_revision', 'prov_entity'),
+ ('prov_qualified_attribution', 'prov_entity'),
+ ('prov_qualified_invalidation', 'prov_entity'),
+ ('prov_qualified_start', 'prov_activity'),
+ ('prov_qualified_usage', 'prov_activity'),
+ ('prov_qualified_communication', 'prov_activity'),
+ ('prov_qualified_association', 'prov_activity'),
+ ('prov_qualified_end', 'prov_activity'),
+ ('prov_qualified_delegation', 'prov_agent'),
+ ('prov_influencer', 'prov_influence'),
+ ('prov_entity', 'prov_entity_influence'),
+ ('prov_had_usage', 'prov_derivation'),
+ ('prov_had_generation', 'prov_derivation'),
+ ('prov_activity', 'prov_activity_influence'),
+ ('prov_agent', 'prov_agent_influence'),
+ ('prov_had_plan', 'prov_association'),
+ ('prov_had_activity', 'prov_delegation'),
+ ('prov_had_activity', 'prov_derivation'),
+ ('prov_had_activity', 'prov_end'),
+ ('prov_had_activity', 'prov_start'),
+ ('prov_at_time', 'prov_instantaneous_event'),
+ ('prov_had_role', 'prov_association'),
+ ('prov_had_role', 'prov_instantaneous_event')
+on conflict do nothing;
+
+insert into provenance_relation_resource_range (relation_code, range_class_code) values
+ ('prov_was_generated_by', 'prov_activity'),
+ ('prov_was_derived_from', 'prov_entity'),
+ ('prov_was_attributed_to', 'prov_agent'),
+ ('prov_used', 'prov_entity'),
+ ('prov_was_informed_by', 'prov_activity'),
+ ('prov_was_associated_with', 'prov_agent'),
+ ('prov_acted_on_behalf_of', 'prov_agent'),
+ ('prov_alternate_of', 'prov_entity'),
+ ('prov_specialization_of', 'prov_entity'),
+ ('prov_had_primary_source', 'prov_entity'),
+ ('prov_was_quoted_from', 'prov_entity'),
+ ('prov_was_revision_of', 'prov_entity'),
+ ('prov_was_invalidated_by', 'prov_activity'),
+ ('prov_had_member', 'prov_entity'),
+ ('prov_was_started_by', 'prov_entity'),
+ ('prov_was_ended_by', 'prov_entity'),
+ ('prov_invalidated', 'prov_entity'),
+ ('prov_influenced', 'prov_entity'),
+ ('prov_influenced', 'prov_activity'),
+ ('prov_influenced', 'prov_agent'),
+ ('prov_at_location', 'prov_location'),
+ ('prov_generated', 'prov_entity'),
+ ('prov_was_influenced_by', 'prov_entity'),
+ ('prov_was_influenced_by', 'prov_activity'),
+ ('prov_was_influenced_by', 'prov_agent'),
+ ('prov_qualified_influence', 'prov_influence'),
+ ('prov_qualified_generation', 'prov_generation'),
+ ('prov_qualified_derivation', 'prov_derivation'),
+ ('prov_qualified_primary_source', 'prov_primary_source'),
+ ('prov_qualified_quotation', 'prov_quotation'),
+ ('prov_qualified_revision', 'prov_revision'),
+ ('prov_qualified_attribution', 'prov_attribution'),
+ ('prov_qualified_invalidation', 'prov_invalidation'),
+ ('prov_qualified_start', 'prov_start'),
+ ('prov_qualified_usage', 'prov_usage'),
+ ('prov_qualified_communication', 'prov_communication'),
+ ('prov_qualified_association', 'prov_association'),
+ ('prov_qualified_end', 'prov_end'),
+ ('prov_qualified_delegation', 'prov_delegation'),
+ ('prov_influencer', 'prov_entity'),
+ ('prov_influencer', 'prov_activity'),
+ ('prov_influencer', 'prov_agent'),
+ ('prov_entity', 'prov_entity'),
+ ('prov_had_usage', 'prov_usage'),
+ ('prov_had_generation', 'prov_generation'),
+ ('prov_activity', 'prov_activity'),
+ ('prov_agent', 'prov_agent'),
+ ('prov_had_plan', 'prov_plan'),
+ ('prov_had_activity', 'prov_activity'),
+ ('prov_had_role', 'prov_role')
+on conflict do nothing;
+
+insert into provenance_qualification_definition (unqualified_relation_code, qualification_relation_code, influence_class_code, influencer_relation_code) values
+ ('prov_was_generated_by', 'prov_qualified_generation', 'prov_generation', 'prov_activity'),
+ ('prov_was_derived_from', 'prov_qualified_derivation', 'prov_derivation', 'prov_entity'),
+ ('prov_was_attributed_to', 'prov_qualified_attribution', 'prov_attribution', 'prov_agent'),
+ ('prov_used', 'prov_qualified_usage', 'prov_usage', 'prov_entity'),
+ ('prov_was_informed_by', 'prov_qualified_communication', 'prov_communication', 'prov_activity'),
+ ('prov_was_associated_with', 'prov_qualified_association', 'prov_association', 'prov_agent'),
+ ('prov_acted_on_behalf_of', 'prov_qualified_delegation', 'prov_delegation', 'prov_agent'),
+ ('prov_was_influenced_by', 'prov_qualified_influence', 'prov_influence', 'prov_influencer'),
+ ('prov_had_primary_source', 'prov_qualified_primary_source', 'prov_primary_source', 'prov_entity'),
+ ('prov_was_quoted_from', 'prov_qualified_quotation', 'prov_quotation', 'prov_entity'),
+ ('prov_was_revision_of', 'prov_qualified_revision', 'prov_revision', 'prov_entity'),
+ ('prov_was_invalidated_by', 'prov_qualified_invalidation', 'prov_invalidation', 'prov_activity'),
+ ('prov_was_started_by', 'prov_qualified_start', 'prov_start', 'prov_entity'),
+ ('prov_was_ended_by', 'prov_qualified_end', 'prov_end', 'prov_entity')
+on conflict (unqualified_relation_code) do update set
+ qualification_relation_code = excluded.qualification_relation_code,
+ influence_class_code = excluded.influence_class_code,
+ influencer_relation_code = excluded.influencer_relation_code;
+
+insert into provenance_inverse_definition (relation_code, inverse_local_name, inverse_iri, inverse_relation_code, inverse_kind_code) values
+ ('prov_acted_on_behalf_of', 'hadDelegate', 'http://www.w3.org/ns/prov#hadDelegate', null, 'recommended'),
+ ('prov_activity', 'activityOfInfluence', 'http://www.w3.org/ns/prov#activityOfInfluence', null, 'recommended'),
+ ('prov_agent', 'agentOfInfluence', 'http://www.w3.org/ns/prov#agentOfInfluence', null, 'recommended'),
+ ('prov_alternate_of', 'alternateOf', 'http://www.w3.org/ns/prov#alternateOf', 'prov_alternate_of', 'defined'),
+ ('prov_at_location', 'locationOf', 'http://www.w3.org/ns/prov#locationOf', null, 'recommended'),
+ ('prov_entity', 'entityOfInfluence', 'http://www.w3.org/ns/prov#entityOfInfluence', null, 'recommended'),
+ ('prov_generated', 'wasGeneratedBy', 'http://www.w3.org/ns/prov#wasGeneratedBy', 'prov_was_generated_by', 'defined'),
+ ('prov_had_activity', 'wasActivityOfInfluence', 'http://www.w3.org/ns/prov#wasActivityOfInfluence', null, 'recommended'),
+ ('prov_had_generation', 'generatedAsDerivation', 'http://www.w3.org/ns/prov#generatedAsDerivation', null, 'recommended'),
+ ('prov_had_member', 'wasMemberOf', 'http://www.w3.org/ns/prov#wasMemberOf', null, 'recommended'),
+ ('prov_had_plan', 'wasPlanOf', 'http://www.w3.org/ns/prov#wasPlanOf', null, 'recommended'),
+ ('prov_had_primary_source', 'wasPrimarySourceOf', 'http://www.w3.org/ns/prov#wasPrimarySourceOf', null, 'recommended'),
+ ('prov_had_role', 'wasRoleIn', 'http://www.w3.org/ns/prov#wasRoleIn', null, 'recommended'),
+ ('prov_had_usage', 'wasUsedInDerivation', 'http://www.w3.org/ns/prov#wasUsedInDerivation', null, 'recommended'),
+ ('prov_influenced', 'wasInfluencedBy', 'http://www.w3.org/ns/prov#wasInfluencedBy', 'prov_was_influenced_by', 'defined'),
+ ('prov_influencer', 'hadInfluence', 'http://www.w3.org/ns/prov#hadInfluence', null, 'recommended'),
+ ('prov_invalidated', 'wasInvalidatedBy', 'http://www.w3.org/ns/prov#wasInvalidatedBy', 'prov_was_invalidated_by', 'defined'),
+ ('prov_qualified_association', 'qualifiedAssociationOf', 'http://www.w3.org/ns/prov#qualifiedAssociationOf', null, 'recommended'),
+ ('prov_qualified_attribution', 'qualifiedAttributionOf', 'http://www.w3.org/ns/prov#qualifiedAttributionOf', null, 'recommended'),
+ ('prov_qualified_communication', 'qualifiedCommunicationOf', 'http://www.w3.org/ns/prov#qualifiedCommunicationOf', null, 'recommended'),
+ ('prov_qualified_delegation', 'qualifiedDelegationOf', 'http://www.w3.org/ns/prov#qualifiedDelegationOf', null, 'recommended'),
+ ('prov_qualified_derivation', 'qualifiedDerivationOf', 'http://www.w3.org/ns/prov#qualifiedDerivationOf', null, 'recommended'),
+ ('prov_qualified_end', 'qualifiedEndOf', 'http://www.w3.org/ns/prov#qualifiedEndOf', null, 'recommended'),
+ ('prov_qualified_generation', 'qualifiedGenerationOf', 'http://www.w3.org/ns/prov#qualifiedGenerationOf', null, 'recommended'),
+ ('prov_qualified_influence', 'qualifiedInfluenceOf', 'http://www.w3.org/ns/prov#qualifiedInfluenceOf', null, 'recommended'),
+ ('prov_qualified_invalidation', 'qualifiedInvalidationOf', 'http://www.w3.org/ns/prov#qualifiedInvalidationOf', null, 'recommended'),
+ ('prov_qualified_primary_source', 'qualifiedSourceOf', 'http://www.w3.org/ns/prov#qualifiedSourceOf', null, 'recommended'),
+ ('prov_qualified_quotation', 'qualifiedQuotationOf', 'http://www.w3.org/ns/prov#qualifiedQuotationOf', null, 'recommended'),
+ ('prov_qualified_revision', 'revisedEntity', 'http://www.w3.org/ns/prov#revisedEntity', null, 'recommended'),
+ ('prov_qualified_start', 'qualifiedStartOf', 'http://www.w3.org/ns/prov#qualifiedStartOf', null, 'recommended'),
+ ('prov_qualified_usage', 'qualifiedUsingActivity', 'http://www.w3.org/ns/prov#qualifiedUsingActivity', null, 'recommended'),
+ ('prov_specialization_of', 'generalizationOf', 'http://www.w3.org/ns/prov#generalizationOf', null, 'recommended'),
+ ('prov_used', 'wasUsedBy', 'http://www.w3.org/ns/prov#wasUsedBy', null, 'recommended'),
+ ('prov_was_associated_with', 'wasAssociateFor', 'http://www.w3.org/ns/prov#wasAssociateFor', null, 'recommended'),
+ ('prov_was_attributed_to', 'contributed', 'http://www.w3.org/ns/prov#contributed', null, 'recommended'),
+ ('prov_was_derived_from', 'hadDerivation', 'http://www.w3.org/ns/prov#hadDerivation', null, 'recommended'),
+ ('prov_was_ended_by', 'ended', 'http://www.w3.org/ns/prov#ended', null, 'recommended'),
+ ('prov_was_generated_by', 'generated', 'http://www.w3.org/ns/prov#generated', 'prov_generated', 'defined'),
+ ('prov_was_influenced_by', 'influenced', 'http://www.w3.org/ns/prov#influenced', 'prov_influenced', 'defined'),
+ ('prov_was_informed_by', 'informed', 'http://www.w3.org/ns/prov#informed', null, 'recommended'),
+ ('prov_was_invalidated_by', 'invalidated', 'http://www.w3.org/ns/prov#invalidated', 'prov_invalidated', 'defined'),
+ ('prov_was_quoted_from', 'quotedAs', 'http://www.w3.org/ns/prov#quotedAs', null, 'recommended'),
+ ('prov_was_revision_of', 'hadRevision', 'http://www.w3.org/ns/prov#hadRevision', null, 'recommended'),
+ ('prov_was_started_by', 'started', 'http://www.w3.org/ns/prov#started', null, 'recommended')
+on conflict (relation_code) do update set
+ inverse_local_name = excluded.inverse_local_name,
+ inverse_iri = excluded.inverse_iri,
+ inverse_relation_code = excluded.inverse_relation_code,
+ inverse_kind_code = excluded.inverse_kind_code;
+
+commit;
diff --git a/migrations/0018_analysis_run_registry.sql b/migrations/0018_analysis_run_registry.sql
new file mode 100644
index 000000000..b08d80b3b
--- /dev/null
+++ b/migrations/0018_analysis_run_registry.sql
@@ -0,0 +1,569 @@
+-- Milestone 2 additive runtime bridge: normalized analysis-run registry.
+--
+-- This migration records reproducibility, authorization scope, aggregate
+-- reconciliation, and lifecycle evidence without storing source SQL, DSNs,
+-- raw records, image bytes, provider payloads, credentials, or free-form JSON.
+-- Snapshot availability is evidence-owned; the knowledge cutoff is run-owned,
+-- so one immutable capture can support multiple historically valid analyses.
+
+begin;
+
+insert into common_lookup_value
+ (lookup_category, lookup_code, lookup_label, display_order)
+values
+ ('analysis_run_kind', 'analysis_run_lineage', 'Lineage reconstruction', 0),
+ ('analysis_run_kind', 'analysis_run_report', 'Period report', 1),
+ ('analysis_run_kind', 'analysis_run_tepp', 'TEPP measurement', 2),
+ ('analysis_run_status', 'analysis_status_pending', 'Pending', 0),
+ ('analysis_run_status', 'analysis_status_running', 'Running', 1),
+ ('analysis_run_status', 'analysis_status_succeeded', 'Succeeded', 2),
+ ('analysis_run_status', 'analysis_status_failed', 'Failed', 3),
+ ('analysis_run_status', 'analysis_status_cancelled', 'Cancelled', 4),
+ ('analysis_run_scope', 'analysis_scope_all_visible', 'All authorized records', 0),
+ ('analysis_run_scope', 'analysis_scope_corporate_entity', 'Corporate entity', 1),
+ ('analysis_run_scope', 'analysis_scope_process_unit', 'Process unit', 2),
+ ('analysis_run_scope', 'analysis_scope_thread_group', 'Thread group', 3),
+ ('analysis_source_count', 'analysis_count_source_row', 'Source rows', 0),
+ ('analysis_source_count', 'analysis_count_document', 'Documents', 1),
+ ('analysis_source_count', 'analysis_count_thread', 'Threads', 2),
+ ('analysis_source_count', 'analysis_count_lineage_node', 'Lineage nodes', 3),
+ ('analysis_source_count', 'analysis_count_lineage_edge', 'Lineage edges', 4)
+on conflict (lookup_code) do nothing;
+
+-- common_lookup_value deliberately makes lookup_code globally unique. A code
+-- that already exists under another category is a migration conflict rather
+-- than permission to attach the wrong vocabulary to an analysis column.
+do $$
+declare
+ lookup_mismatch_count integer;
+begin
+ select count(*)
+ into lookup_mismatch_count
+ from common_lookup_value as actual
+ join (values
+ ('analysis_run_lineage', 'analysis_run_kind'),
+ ('analysis_run_report', 'analysis_run_kind'),
+ ('analysis_run_tepp', 'analysis_run_kind'),
+ ('analysis_status_pending', 'analysis_run_status'),
+ ('analysis_status_running', 'analysis_run_status'),
+ ('analysis_status_succeeded', 'analysis_run_status'),
+ ('analysis_status_failed', 'analysis_run_status'),
+ ('analysis_status_cancelled', 'analysis_run_status'),
+ ('analysis_scope_all_visible', 'analysis_run_scope'),
+ ('analysis_scope_corporate_entity', 'analysis_run_scope'),
+ ('analysis_scope_process_unit', 'analysis_run_scope'),
+ ('analysis_scope_thread_group', 'analysis_run_scope'),
+ ('analysis_count_source_row', 'analysis_source_count'),
+ ('analysis_count_document', 'analysis_source_count'),
+ ('analysis_count_thread', 'analysis_source_count'),
+ ('analysis_count_lineage_node', 'analysis_source_count'),
+ ('analysis_count_lineage_edge', 'analysis_source_count')
+ ) as expected(lookup_code, lookup_category)
+ on expected.lookup_code = actual.lookup_code
+ where actual.lookup_category <> expected.lookup_category;
+
+ if lookup_mismatch_count <> 0 then
+ raise exception 'analysis_run_registry_lookup_conflict';
+ end if;
+end
+$$;
+
+create table if not exists analysis_source_snapshot (
+ analysis_source_snapshot_id uuid primary key default uuid_generate_v4(),
+ snapshot_sha256 text not null unique,
+ source_contract_version text not null,
+ maximum_available_time timestamptz not null,
+ captured_at timestamptz not null,
+ created_at timestamptz not null default now(),
+ constraint analysis_source_snapshot_digest_check
+ check (snapshot_sha256 ~ '^[0-9a-f]{64}$'),
+ constraint analysis_source_snapshot_contract_check
+ check (length(btrim(source_contract_version)) between 1 and 128),
+ constraint analysis_source_snapshot_capture_check
+ check (maximum_available_time <= captured_at),
+ constraint analysis_source_snapshot_created_check
+ check (captured_at <= created_at)
+);
+
+comment on table analysis_source_snapshot is
+ 'Immutable captured-source identity and latest evidence-availability time; '
+ 'knowledge cutoffs belong to analysis_run, not the reusable snapshot.';
+
+create table if not exists analysis_source_count (
+ analysis_source_snapshot_id uuid not null
+ references analysis_source_snapshot (analysis_source_snapshot_id)
+ on delete cascade,
+ count_type_code text not null
+ references common_lookup_value (lookup_code),
+ count_value bigint not null,
+ primary key (analysis_source_snapshot_id, count_type_code),
+ constraint analysis_source_count_type_check
+ check (count_type_code in (
+ 'analysis_count_source_row',
+ 'analysis_count_document',
+ 'analysis_count_thread',
+ 'analysis_count_lineage_node',
+ 'analysis_count_lineage_edge'
+ )),
+ constraint analysis_source_count_nonnegative_check
+ check (count_value >= 0)
+);
+
+comment on table analysis_source_count is
+ 'One normalized aggregate reconciliation count per immutable snapshot and '
+ 'count vocabulary; no source record is stored.';
+
+create table if not exists analysis_run (
+ analysis_run_id uuid primary key default uuid_generate_v4(),
+ analysis_source_snapshot_id uuid not null
+ references analysis_source_snapshot (analysis_source_snapshot_id),
+ run_kind_code text not null
+ references common_lookup_value (lookup_code),
+ requested_by_account_id uuid not null
+ references user_account (user_account_id),
+ idempotency_key text not null,
+ knowledge_cutoff timestamptz not null,
+ configuration_schema_version text not null,
+ configuration_sha256 text not null,
+ model_contract_sha256 text,
+ prompt_bundle_sha256 text,
+ code_revision_sha text not null,
+ requested_at timestamptz not null default now(),
+ constraint analysis_run_kind_check
+ check (run_kind_code in (
+ 'analysis_run_lineage',
+ 'analysis_run_report',
+ 'analysis_run_tepp'
+ )),
+ constraint analysis_run_idempotency_key_check
+ check (
+ idempotency_key = btrim(idempotency_key)
+ and length(idempotency_key) between 1 and 256
+ and idempotency_key !~ '[[:cntrl:]]'
+ ),
+ constraint analysis_run_configuration_version_check
+ check (
+ configuration_schema_version = btrim(configuration_schema_version)
+ and length(configuration_schema_version) between 1 and 128
+ ),
+ constraint analysis_run_configuration_digest_check
+ check (configuration_sha256 ~ '^[0-9a-f]{64}$'),
+ constraint analysis_run_model_digest_check
+ check (
+ model_contract_sha256 is null
+ or model_contract_sha256 ~ '^[0-9a-f]{64}$'
+ ),
+ constraint analysis_run_prompt_digest_check
+ check (
+ prompt_bundle_sha256 is null
+ or prompt_bundle_sha256 ~ '^[0-9a-f]{64}$'
+ ),
+ constraint analysis_run_code_revision_check
+ check (code_revision_sha ~ '^(?:[0-9a-f]{40}|[0-9a-f]{64})$'),
+ constraint analysis_run_request_time_check
+ check (knowledge_cutoff <= requested_at),
+ unique (requested_by_account_id, idempotency_key)
+);
+
+create index if not exists analysis_run_snapshot_idx
+ on analysis_run (analysis_source_snapshot_id);
+create index if not exists analysis_run_kind_requested_idx
+ on analysis_run (run_kind_code, requested_at desc);
+create index if not exists analysis_run_requester_idx
+ on analysis_run (requested_by_account_id, requested_at desc);
+
+comment on table analysis_run is
+ 'Immutable account-scoped analysis request bound to one snapshot, one '
+ 'knowledge cutoff, and reproducibility digests; lifecycle is event-derived.';
+
+create table if not exists analysis_run_scope (
+ analysis_run_id uuid primary key
+ references analysis_run (analysis_run_id),
+ scope_kind_code text not null
+ references common_lookup_value (lookup_code),
+ corporate_entity_id uuid
+ references corporate_entity (corporate_entity_id),
+ process_unit_id uuid
+ references process_unit (process_unit_id),
+ scope_key text,
+ constraint analysis_run_scope_kind_check
+ check (scope_kind_code in (
+ 'analysis_scope_all_visible',
+ 'analysis_scope_corporate_entity',
+ 'analysis_scope_process_unit',
+ 'analysis_scope_thread_group'
+ )),
+ constraint analysis_run_scope_shape_check
+ check (
+ (scope_kind_code = 'analysis_scope_all_visible'
+ and corporate_entity_id is null
+ and process_unit_id is null
+ and scope_key is null)
+ or
+ (scope_kind_code = 'analysis_scope_corporate_entity'
+ and corporate_entity_id is not null
+ and process_unit_id is null
+ and scope_key is null)
+ or
+ (scope_kind_code = 'analysis_scope_process_unit'
+ and corporate_entity_id is null
+ and process_unit_id is not null
+ and scope_key is null)
+ or
+ (scope_kind_code = 'analysis_scope_thread_group'
+ and corporate_entity_id is null
+ and process_unit_id is null
+ and scope_key is not null
+ and scope_key = btrim(scope_key)
+ and length(scope_key) between 1 and 256
+ and scope_key !~ '[[:cntrl:]]')
+ )
+);
+
+create index if not exists analysis_run_scope_entity_idx
+ on analysis_run_scope (corporate_entity_id)
+ where corporate_entity_id is not null;
+create index if not exists analysis_run_scope_unit_idx
+ on analysis_run_scope (process_unit_id)
+ where process_unit_id is not null;
+
+comment on table analysis_run_scope is
+ 'One immutable authorization-relevant scope is required before lifecycle '
+ 'evidence; process-unit ownership remains derivable from process_unit.';
+
+create table if not exists analysis_run_status_event (
+ analysis_run_id uuid not null
+ references analysis_run (analysis_run_id),
+ status_ordinal integer not null,
+ status_code text not null
+ references common_lookup_value (lookup_code),
+ occurred_at timestamptz not null,
+ recorded_at timestamptz not null default clock_timestamp(),
+ failure_code text,
+ retryable boolean not null default false,
+ primary key (analysis_run_id, status_ordinal),
+ constraint analysis_run_status_code_check
+ check (status_code in (
+ 'analysis_status_pending',
+ 'analysis_status_running',
+ 'analysis_status_succeeded',
+ 'analysis_status_failed',
+ 'analysis_status_cancelled'
+ )),
+ constraint analysis_run_status_ordinal_check
+ check (status_ordinal >= 1),
+ constraint analysis_run_status_time_check
+ check (occurred_at <= recorded_at),
+ constraint analysis_run_status_failure_shape_check
+ check (
+ (status_code = 'analysis_status_failed'
+ and failure_code is not null
+ and failure_code ~ '^[a-z][a-z0-9_]{0,127}$')
+ or
+ (status_code <> 'analysis_status_failed'
+ and failure_code is null
+ and retryable = false)
+ )
+);
+
+create index if not exists analysis_run_status_current_idx
+ on analysis_run_status_event (analysis_run_id, status_ordinal desc);
+
+comment on table analysis_run_status_event is
+ 'Append-only, contiguous, monotonic state-machine evidence; failure_code is '
+ 'a bounded machine code and never contains raw provider or source payloads.';
+
+create or replace function reject_analysis_source_snapshot_update()
+returns trigger
+language plpgsql
+as $$
+begin
+ raise exception 'analysis_source_snapshot_is_immutable';
+end
+$$;
+
+comment on function reject_analysis_source_snapshot_update() is
+ 'Rejects mutation of captured source identity and availability evidence.';
+
+drop trigger if exists analysis_source_snapshot_update_reject
+ on analysis_source_snapshot;
+create trigger analysis_source_snapshot_update_reject
+before update on analysis_source_snapshot
+for each row execute function reject_analysis_source_snapshot_update();
+
+create or replace function reject_analysis_source_count_update()
+returns trigger
+language plpgsql
+as $$
+begin
+ raise exception 'analysis_source_count_is_immutable';
+end
+$$;
+
+comment on function reject_analysis_source_count_update() is
+ 'Rejects replacement of a snapshot aggregate; delete and reinsert is only '
+ 'permitted before the snapshot is attached to a run.';
+
+drop trigger if exists analysis_source_count_update_reject
+ on analysis_source_count;
+create trigger analysis_source_count_update_reject
+before update on analysis_source_count
+for each row execute function reject_analysis_source_count_update();
+
+create or replace function enforce_analysis_source_count_freeze()
+returns trigger
+language plpgsql
+as $$
+declare
+ affected_snapshot_id uuid;
+begin
+ if tg_op = 'DELETE' then
+ affected_snapshot_id := old.analysis_source_snapshot_id;
+ else
+ affected_snapshot_id := new.analysis_source_snapshot_id;
+ end if;
+
+ -- Both count mutation and run creation lock this row first. That common
+ -- lock order closes the race between the final count write and first run.
+ perform 1
+ from analysis_source_snapshot
+ where analysis_source_snapshot_id = affected_snapshot_id
+ for update;
+
+ if exists (
+ select 1
+ from analysis_run
+ where analysis_source_snapshot_id = affected_snapshot_id
+ ) then
+ raise exception 'analysis_source_count_frozen_after_run';
+ end if;
+
+ if tg_op = 'DELETE' then
+ return old;
+ end if;
+ return new;
+end
+$$;
+
+comment on function enforce_analysis_source_count_freeze() is
+ 'Serializes count insert/delete against first run creation and rejects '
+ 'changes after any run references the snapshot.';
+
+drop trigger if exists analysis_source_count_freeze_guard
+ on analysis_source_count;
+create trigger analysis_source_count_freeze_guard
+before insert or delete on analysis_source_count
+for each row execute function enforce_analysis_source_count_freeze();
+
+create or replace function enforce_analysis_run_knowledge_cutoff()
+returns trigger
+language plpgsql
+as $$
+declare
+ snapshot_available_time timestamptz;
+ snapshot_capture_time timestamptz;
+begin
+ if new.requested_at > clock_timestamp() then
+ raise exception 'analysis_run_request_time_in_future';
+ end if;
+
+ select maximum_available_time, captured_at
+ into snapshot_available_time, snapshot_capture_time
+ from analysis_source_snapshot
+ where analysis_source_snapshot_id = new.analysis_source_snapshot_id
+ for update;
+
+ if not found then
+ raise exception 'analysis_source_snapshot_not_found';
+ end if;
+ if snapshot_available_time > new.knowledge_cutoff then
+ raise exception 'analysis_run_future_information_leakage';
+ end if;
+ if snapshot_capture_time > new.requested_at then
+ raise exception 'analysis_run_snapshot_captured_after_request';
+ end if;
+ return new;
+end
+$$;
+
+comment on function enforce_analysis_run_knowledge_cutoff() is
+ 'Locks the immutable snapshot and rejects run cutoffs earlier than the '
+ 'latest admitted evidence or requests earlier than snapshot capture.';
+
+drop trigger if exists analysis_run_knowledge_cutoff_guard
+ on analysis_run;
+create trigger analysis_run_knowledge_cutoff_guard
+before insert on analysis_run
+for each row execute function enforce_analysis_run_knowledge_cutoff();
+
+drop trigger if exists analysis_run_update_reject
+ on analysis_run;
+drop trigger if exists analysis_run_mutation_reject
+ on analysis_run;
+drop function if exists reject_analysis_run_update();
+
+create or replace function reject_analysis_run_mutation()
+returns trigger
+language plpgsql
+as $$
+begin
+ raise exception 'analysis_run_request_is_immutable';
+end
+$$;
+
+comment on function reject_analysis_run_mutation() is
+ 'Rejects update or delete of actor, cutoff, idempotency, and reproducibility '
+ 'evidence; run progress belongs to append-only status events.';
+
+create trigger analysis_run_mutation_reject
+before update or delete on analysis_run
+for each row execute function reject_analysis_run_mutation();
+
+create or replace function reject_analysis_run_scope_mutation()
+returns trigger
+language plpgsql
+as $$
+begin
+ raise exception 'analysis_run_scope_is_immutable';
+end
+$$;
+
+comment on function reject_analysis_run_scope_mutation() is
+ 'Rejects update or delete of the authorization-relevant scope attached to '
+ 'an immutable analysis request.';
+
+drop trigger if exists analysis_run_scope_mutation_reject
+ on analysis_run_scope;
+create trigger analysis_run_scope_mutation_reject
+before update or delete on analysis_run_scope
+for each row execute function reject_analysis_run_scope_mutation();
+
+create or replace function reject_analysis_run_status_mutation()
+returns trigger
+language plpgsql
+as $$
+begin
+ raise exception 'analysis_run_status_event_is_append_only';
+end
+$$;
+
+comment on function reject_analysis_run_status_mutation() is
+ 'Rejects update or delete of state-machine evidence.';
+
+drop trigger if exists analysis_run_status_event_update_reject
+ on analysis_run_status_event;
+create trigger analysis_run_status_event_update_reject
+before update on analysis_run_status_event
+for each row execute function reject_analysis_run_status_mutation();
+
+drop trigger if exists analysis_run_status_event_delete_reject
+ on analysis_run_status_event;
+create trigger analysis_run_status_event_delete_reject
+before delete on analysis_run_status_event
+for each row execute function reject_analysis_run_status_mutation();
+
+create or replace function enforce_analysis_run_status_transition()
+returns trigger
+language plpgsql
+as $$
+declare
+ previous_ordinal integer;
+ previous_status_code text;
+ previous_occurred_at timestamptz;
+ run_requested_at timestamptz;
+begin
+ -- The immutable parent row is a per-run serialization lock. It prevents
+ -- concurrent writers from both accepting the same next ordinal.
+ select requested_at
+ into run_requested_at
+ from analysis_run
+ where analysis_run_id = new.analysis_run_id
+ for update;
+
+ if not found then
+ raise exception 'analysis_run_not_found';
+ end if;
+ if not exists (
+ select 1 from analysis_run_scope
+ where analysis_run_id = new.analysis_run_id
+ ) then
+ raise exception 'analysis_run_scope_required';
+ end if;
+ if new.occurred_at < run_requested_at then
+ raise exception 'analysis_run_status_before_request';
+ end if;
+ new.recorded_at := clock_timestamp();
+
+ select status_ordinal, status_code, occurred_at
+ into previous_ordinal, previous_status_code, previous_occurred_at
+ from analysis_run_status_event
+ where analysis_run_id = new.analysis_run_id
+ order by status_ordinal desc
+ limit 1;
+
+ if previous_ordinal is null then
+ if new.status_ordinal <> 1
+ or new.status_code <> 'analysis_status_pending' then
+ raise exception 'analysis_run_first_status_must_be_pending';
+ end if;
+ return new;
+ end if;
+
+ if new.status_ordinal <> previous_ordinal + 1 then
+ raise exception 'analysis_run_status_ordinal_not_contiguous';
+ end if;
+ if new.occurred_at < previous_occurred_at then
+ raise exception 'analysis_run_status_time_not_monotonic';
+ end if;
+
+ if previous_status_code = 'analysis_status_pending' then
+ if new.status_code not in (
+ 'analysis_status_running',
+ 'analysis_status_cancelled'
+ ) then
+ raise exception 'analysis_run_status_transition_invalid';
+ end if;
+ elsif previous_status_code = 'analysis_status_running' then
+ if new.status_code not in (
+ 'analysis_status_succeeded',
+ 'analysis_status_failed',
+ 'analysis_status_cancelled'
+ ) then
+ raise exception 'analysis_run_status_transition_invalid';
+ end if;
+ else
+ raise exception 'analysis_run_terminal_status_has_no_successor';
+ end if;
+
+ return new;
+end
+$$;
+
+comment on function enforce_analysis_run_status_transition() is
+ 'Serializes status appends and requires immutable scope, request-time '
+ 'ordering, database-recorded time, legal transitions, and terminal finality.';
+
+drop trigger if exists analysis_run_status_transition_guard
+ on analysis_run_status_event;
+create trigger analysis_run_status_transition_guard
+before insert on analysis_run_status_event
+for each row execute function enforce_analysis_run_status_transition();
+
+create or replace view analysis_run_current_status as
+select distinct on (status_event.analysis_run_id)
+ status_event.analysis_run_id,
+ status_event.status_code,
+ status_event.status_ordinal,
+ status_event.occurred_at,
+ status_event.recorded_at,
+ status_event.failure_code,
+ status_event.retryable
+ from analysis_run_status_event as status_event
+ order by status_event.analysis_run_id,
+ status_event.status_ordinal desc;
+
+comment on view analysis_run_current_status is
+ 'Latest append-only status projection for each run; never a second mutable '
+ 'lifecycle authority.';
+
+commit;
diff --git a/migrations/0019_role_catalog_identity.sql b/migrations/0019_role_catalog_identity.sql
new file mode 100644
index 000000000..2881be9b3
--- /dev/null
+++ b/migrations/0019_role_catalog_identity.sql
@@ -0,0 +1,46 @@
+-- ADR 0019: bind each R&R role to the catalog row resolved for that
+-- role. corporate_entity.entity_name is not unique, so a fetch join on
+-- name can attach a homonym or duplicate the role. Mention tables are
+-- post-scoped, not role-scoped, and cannot reconstruct that binding.
+
+alter table post_summary_role
+ add column if not exists cataloged_team_id uuid
+ references cataloged_team (team_id);
+
+alter table post_summary_role
+ add column if not exists cataloged_corporate_entity_id uuid
+ references corporate_entity (corporate_entity_id);
+
+-- Teams already have a unique (team_name, affiliated_organization_name)
+-- key. Backfill only when that pair was mentioned on the same post.
+update post_summary_role as role
+ set cataloged_team_id = team.team_id
+ from cataloged_team as team
+ join post_team_mention as mention
+ on mention.team_id = team.team_id
+ where role.actor_type_code = 'prov_team'
+ and role.cataloged_team_id is null
+ and mention.post_id = role.post_id
+ and team.team_name = role.actor_name
+ and team.affiliated_organization_name
+ is not distinct from role.affiliated_organization_name;
+
+-- Organizations: copy a mention only when exactly one mentioned org on
+-- that post has this role's actor_name. Two same-named mentions stay
+-- unbound rather than guessing.
+update post_summary_role role
+ set cataloged_corporate_entity_id = matched.corporate_entity_id
+ from (
+ select mention.post_id,
+ org.entity_name,
+ min(org.corporate_entity_id) as corporate_entity_id
+ from post_organization_mention mention
+ join corporate_entity org
+ on org.corporate_entity_id = mention.corporate_entity_id
+ group by mention.post_id, org.entity_name
+ having count(*) = 1
+ ) matched
+ where role.actor_type_code = 'prov_organization'
+ and role.cataloged_corporate_entity_id is null
+ and role.post_id = matched.post_id
+ and role.actor_name = matched.entity_name;
diff --git a/migrations/0020_analysis_run_retention_purge.sql b/migrations/0020_analysis_run_retention_purge.sql
new file mode 100644
index 000000000..056d80216
--- /dev/null
+++ b/migrations/0020_analysis_run_retention_purge.sql
@@ -0,0 +1,180 @@
+-- Privileged retention purge for the Milestone 2 analysis-run registry.
+--
+-- Migration 0018 makes analysis_run / scope / status immutable, so a
+-- documented "export or delete under an approved retention procedure"
+-- cannot empty a run-bearing registry. This slice adds that procedure:
+-- an audited SECURITY DEFINER purge that disables the immutability
+-- triggers only inside the approved call, then records one retention
+-- event.
+--
+-- Fail-closed authorization is conjunctive (ADR 0020):
+-- 1. session_user holds an unrevoked analysis_run_retention_grant;
+-- 2. session_user is a member of analysis_run_retention_admin;
+-- 3. the documented approval phrase is supplied.
+-- PUBLIC cannot execute the function. The phrase is a procedure name,
+-- not an authorization secret. A session SET cannot authorize a raw
+-- DELETE. Do not grant the admin role to the application DATABASE_URL
+-- login, and do not insert a grant for that login.
+--
+-- ADR 0019 / migration 0019 belong to the R&R catalog-id bind
+-- (cataloged_team_id / cataloged_corporate_entity_id). Do not reuse
+-- that number for this purge.
+
+begin;
+
+do $$
+begin
+ if not exists (
+ select 1 from pg_roles where rolname = 'analysis_run_retention_admin'
+ ) then
+ create role analysis_run_retention_admin nologin nosuperuser inherit;
+ end if;
+end
+$$;
+
+comment on role analysis_run_retention_admin is
+ 'Least-privilege role that may call purge_analysis_run_registry. '
+ 'Grant this role to an operator session, then insert an unrevoked '
+ 'analysis_run_retention_grant for session_user. Do not grant it to '
+ 'the application DATABASE_URL role.';
+
+create table if not exists analysis_run_retention_grant (
+ analysis_run_retention_grant_id uuid primary key default gen_random_uuid(),
+ database_role_name text not null
+ check (char_length(database_role_name) >= 1),
+ granted_at timestamptz not null default clock_timestamp(),
+ revoked_at timestamptz,
+ check (revoked_at is null or revoked_at >= granted_at)
+);
+
+comment on table analysis_run_retention_grant is
+ 'Unrevoked row authorizes session_user to call '
+ 'purge_analysis_run_registry. Insert one grant for the operator '
+ 'role and grant analysis_run_retention_admin before the first purge.';
+
+comment on column analysis_run_retention_grant.database_role_name is
+ 'PostgreSQL session_user that may purge; not an application account.';
+
+create unique index if not exists analysis_run_retention_grant_active
+ on analysis_run_retention_grant (database_role_name)
+ where revoked_at is null;
+
+create table if not exists analysis_run_retention_event (
+ analysis_run_retention_event_id uuid primary key default gen_random_uuid(),
+ approved_at timestamptz not null default clock_timestamp(),
+ purged_run_count bigint not null check (purged_run_count >= 0),
+ purged_snapshot_count bigint not null check (purged_snapshot_count >= 0),
+ approval_token_digest text not null
+ check (approval_token_digest ~ '^[0-9a-f]{64}$'),
+ invoking_session_role name not null,
+ invoking_current_role name not null,
+ client_network_address inet
+);
+
+comment on table analysis_run_retention_event is
+ 'One audit row per approved registry purge; export then delete before '
+ 'rolling back migration 0020.';
+
+comment on column analysis_run_retention_event.approval_token_digest is
+ 'SHA-256 hex of the approval token; the raw phrase is never stored.';
+
+comment on column analysis_run_retention_event.invoking_session_role is
+ 'session_user at purge time: the login role that held the grant.';
+
+comment on column analysis_run_retention_event.invoking_current_role is
+ 'current_user at purge time: the SECURITY DEFINER owner while the '
+ 'function runs.';
+
+comment on column analysis_run_retention_event.client_network_address is
+ 'inet_client_addr() when the caller is remote; NULL for local sockets.';
+
+create or replace function purge_analysis_run_registry(approval_token text)
+returns void
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ run_count bigint;
+ snapshot_count bigint;
+begin
+ if not exists (
+ select 1
+ from analysis_run_retention_grant
+ where database_role_name = session_user
+ and revoked_at is null
+ ) then
+ raise exception 'analysis_run_retention_not_granted';
+ end if;
+
+ if not pg_has_role(session_user, 'analysis_run_retention_admin', 'member') then
+ raise exception 'analysis_run_retention_not_admin';
+ end if;
+
+ if approval_token is distinct from 'approved-retention-purge' then
+ raise exception 'analysis_run_retention_not_approved';
+ end if;
+
+ select count(*) into run_count from analysis_run;
+ select count(*) into snapshot_count from analysis_source_snapshot;
+
+ alter table analysis_run_status_event
+ disable trigger analysis_run_status_event_delete_reject;
+ alter table analysis_run_scope
+ disable trigger analysis_run_scope_mutation_reject;
+ alter table analysis_run
+ disable trigger analysis_run_mutation_reject;
+
+ begin
+ delete from analysis_run_status_event;
+ delete from analysis_run_scope;
+ delete from analysis_run;
+ delete from analysis_source_count;
+ delete from analysis_source_snapshot;
+ exception
+ when others then
+ alter table analysis_run
+ enable trigger analysis_run_mutation_reject;
+ alter table analysis_run_scope
+ enable trigger analysis_run_scope_mutation_reject;
+ alter table analysis_run_status_event
+ enable trigger analysis_run_status_event_delete_reject;
+ raise;
+ end;
+
+ alter table analysis_run
+ enable trigger analysis_run_mutation_reject;
+ alter table analysis_run_scope
+ enable trigger analysis_run_scope_mutation_reject;
+ alter table analysis_run_status_event
+ enable trigger analysis_run_status_event_delete_reject;
+
+ insert into analysis_run_retention_event (
+ purged_run_count,
+ purged_snapshot_count,
+ approval_token_digest,
+ invoking_session_role,
+ invoking_current_role,
+ client_network_address
+ ) values (
+ run_count,
+ snapshot_count,
+ encode(sha256(convert_to(approval_token, 'UTF8')), 'hex'),
+ session_user,
+ current_user,
+ inet_client_addr()
+ );
+end
+$$;
+
+comment on function purge_analysis_run_registry(text) is
+ 'Empties immutable registry relations after an unrevoked role grant, '
+ 'analysis_run_retention_admin membership, and the documented approval '
+ 'token; records one analysis_run_retention_event. Next action: export '
+ 'that event, delete it, then roll back 0020 and 0018.';
+
+revoke all on function purge_analysis_run_registry(text) from public;
+grant execute on function purge_analysis_run_registry(text)
+ to analysis_run_retention_admin;
+
+commit;
diff --git a/migrations/rollback/0018_analysis_run_registry.sql b/migrations/rollback/0018_analysis_run_registry.sql
new file mode 100644
index 000000000..ff35011b2
--- /dev/null
+++ b/migrations/rollback/0018_analysis_run_registry.sql
@@ -0,0 +1,72 @@
+-- Fail-closed rollback for migration 0018.
+--
+-- Registry evidence must be exported, then emptied with
+-- select purge_analysis_run_registry('approved-retention-purge')
+-- (migration 0020 / ADR 0020), before these objects can be removed. A raw
+-- DELETE of analysis_run / scope / status is rejected. Re-running this
+-- rollback after a successful empty rollback is safe.
+
+begin;
+
+do $$
+declare
+ relation_name text;
+ relation_has_rows boolean;
+begin
+ foreach relation_name in array array[
+ 'analysis_run_status_event',
+ 'analysis_run_scope',
+ 'analysis_run',
+ 'analysis_source_count',
+ 'analysis_source_snapshot'
+ ] loop
+ if to_regclass('public.' || relation_name) is not null then
+ execute format('select exists (select 1 from %I)', relation_name)
+ into relation_has_rows;
+ if relation_has_rows then
+ raise exception 'analysis_run_registry_not_empty';
+ end if;
+ end if;
+ end loop;
+end
+$$;
+
+drop view if exists analysis_run_current_status;
+drop table if exists analysis_run_status_event;
+drop table if exists analysis_run_scope;
+drop table if exists analysis_run;
+drop table if exists analysis_source_count;
+drop table if exists analysis_source_snapshot;
+
+drop function if exists enforce_analysis_run_status_transition();
+drop function if exists reject_analysis_run_status_mutation();
+drop function if exists reject_analysis_run_scope_mutation();
+drop function if exists reject_analysis_run_mutation();
+drop function if exists reject_analysis_run_update();
+drop function if exists enforce_analysis_run_knowledge_cutoff();
+drop function if exists enforce_analysis_source_count_freeze();
+drop function if exists reject_analysis_source_count_update();
+drop function if exists reject_analysis_source_snapshot_update();
+
+delete from common_lookup_value
+ where lookup_code in (
+ 'analysis_run_lineage',
+ 'analysis_run_report',
+ 'analysis_run_tepp',
+ 'analysis_status_pending',
+ 'analysis_status_running',
+ 'analysis_status_succeeded',
+ 'analysis_status_failed',
+ 'analysis_status_cancelled',
+ 'analysis_scope_all_visible',
+ 'analysis_scope_corporate_entity',
+ 'analysis_scope_process_unit',
+ 'analysis_scope_thread_group',
+ 'analysis_count_source_row',
+ 'analysis_count_document',
+ 'analysis_count_thread',
+ 'analysis_count_lineage_node',
+ 'analysis_count_lineage_edge'
+ );
+
+commit;
diff --git a/migrations/rollback/0019_role_catalog_identity.sql b/migrations/rollback/0019_role_catalog_identity.sql
new file mode 100644
index 000000000..5efafed8b
--- /dev/null
+++ b/migrations/rollback/0019_role_catalog_identity.sql
@@ -0,0 +1,8 @@
+-- Drop role-scoped catalog identity columns added by 0019.
+-- Mention tables remain; only the role-row binding is removed.
+
+alter table post_summary_role
+ drop column if exists cataloged_team_id;
+
+alter table post_summary_role
+ drop column if exists cataloged_corporate_entity_id;
diff --git a/migrations/rollback/0020_analysis_run_retention_purge.sql b/migrations/rollback/0020_analysis_run_retention_purge.sql
new file mode 100644
index 000000000..fd89611f1
--- /dev/null
+++ b/migrations/rollback/0020_analysis_run_retention_purge.sql
@@ -0,0 +1,33 @@
+-- Fail-closed rollback for migration 0020.
+--
+-- Export analysis_run_retention_event, then delete those rows, before
+-- this script can drop the purge function, grant table, and audit
+-- table. Grant rows are authorization config and drop with the table.
+-- Re-running after a successful empty rollback is safe.
+
+begin;
+
+do $$
+declare
+ relation_has_rows boolean;
+begin
+ if to_regclass('public.analysis_run_retention_event') is not null then
+ execute 'select exists (select 1 from analysis_run_retention_event)'
+ into relation_has_rows;
+ if relation_has_rows then
+ raise exception 'analysis_run_retention_event_not_empty';
+ end if;
+ end if;
+end
+$$;
+
+drop function if exists purge_analysis_run_registry(text);
+drop table if exists analysis_run_retention_grant;
+drop table if exists analysis_run_retention_event;
+
+-- analysis_run_retention_admin is cluster-scoped. Leave it in place so a
+-- parallel database that still has 0020 applied does not lose the role.
+-- Revoke leftover memberships before dropping the role in a dedicated
+-- cluster teardown.
+
+commit;
diff --git a/pyproject.toml b/pyproject.toml
index 9a2272d3a..ecfe24877 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
-version = "0.71.0"
+version = "0.87.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" }
@@ -17,8 +17,8 @@ dependencies = [
# distributions don't reliably inherit the OS trust store.
"certifi>=2024.0.0",
# The standard Python RDF/OWL library -- parses and validates
- # docs/ontology/lineageweave-kg.ttl (ADR 0004). Pure Python, no
- # Rust/C toolchain, unlike fast-mlsirm.
+ # docs/ontology/lineageweave-kg.ttl and the standards-complete PROV-O
+ # support profile (ADR 0011). Pure Python, no Rust/C toolchain.
"rdflib>=7.0.0",
]
@@ -26,6 +26,7 @@ dependencies = [
dev = [
"pillow>=12.3.0",
"psycopg2-binary>=2.9.12",
+ "coverage>=7.6",
"pyjwt[crypto]>=2.8.0",
"pytest>=8.0",
"httpx>=0.27.0",
diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index 72318f33a..2f3c66c45 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -13,12 +13,14 @@
HTTP goes through ``lineageweave.http_client`` (http(s) allowlist).
-Usage: python3 scripts/seed_demo_data.py [--postgres-dsn ...] [--keycloak-base-url ...] [--valkey-url ...]
+Usage: KEYCLOAK_ADMIN_PASSWORD=... python3 scripts/seed_demo_data.py [--postgres-dsn ...] [--keycloak-base-url ...] [--valkey-url ...]
"""
from __future__ import annotations
import argparse
+import hashlib
+import os
import sys
from pathlib import Path
from urllib.parse import urlencode
@@ -29,14 +31,20 @@
import psycopg2
from lineageweave.http_client import get_json_list, post_form
+from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable
REALM = "lineageweave-demo"
DEFAULT_POSTGRES_DSN = "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave"
DEFAULT_KEYCLOAK_BASE_URL = "http://localhost:18080"
-DEFAULT_KEYCLOAK_ADMIN_USER = "admin"
-DEFAULT_KEYCLOAK_ADMIN_PASSWORD = "admin_dev_only" # nosec B105 -- throwaway local-dev-only Keycloak seed credential
+DEFAULT_KEYCLOAK_ADMIN_USER = os.environ.get("KEYCLOAK_ADMIN", "admin")
DEFAULT_VALKEY_URL = "redis://localhost:16379/0"
+# ADR 0013: one Demo Corp capture, many runs (lineage + TEPP).
+DEMO_SOURCE_SNAPSHOT_MATERIAL = b"lineageweave-synthetic-demo-snapshot-v1"
+DEMO_SOURCE_CONTRACT_VERSION = "demo-source-contract-v1"
+DEMO_LINEAGE_IDEMPOTENCY_KEY = "demo-lineage-seed-2026-w02"
+DEMO_TEPP_IDEMPOTENCY_KEY = "demo-tepp-seed-2026-w02"
+
# (post_title, ticket_title, due_date) -- Event Lineage fixtures a report
# member click opens. Activity seed uses the same titles so Valkey matches.
FIXTURE_TICKET_SPECS = (
@@ -106,6 +114,14 @@ def seed(
cur.execute((migrations / "0009_shared_metric_bank.sql").read_text())
cur.execute((migrations / "0010_report_item_information.sql").read_text())
cur.execute((migrations / "0011_post_chat_result.sql").read_text())
+ cur.execute((migrations / "0012_role_responsibility_agent_type.sql").read_text())
+ cur.execute((migrations / "0013_person_job_title.sql").read_text())
+ cur.execute((migrations / "0014_role_responsibility_team_actor_type.sql").read_text())
+ cur.execute((migrations / "0015_organization_name_resolution.sql").read_text())
+ cur.execute((migrations / "0016_cross_post_actor_identity.sql").read_text())
+ cur.execute((migrations / "0018_analysis_run_registry.sql").read_text())
+ cur.execute((migrations / "0019_role_catalog_identity.sql").read_text())
+ cur.execute((migrations / "0020_analysis_run_retention_purge.sql").read_text())
cur.execute(
"""
insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values
@@ -217,18 +233,23 @@ def seed(
cur.execute("select post_id from source_post where post_title = 'Demo public post'")
if cur.fetchone() is None:
cur.execute(
- "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code) "
+ "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code, created_at) "
"values (%s, %s, %s, 'Demo public post', "
"'Ada West at Demo Corp followed up with Priya Nair at Northridge Grid about the delayed shipment.', "
- "'voc', 'public')",
+ "'voc', 'public', '2026-01-10T12:00:00Z')",
(account_ids["demo.analyst"], corporate_entity_id, process_units["DEMO-PU-A"]),
)
cur.execute(
- "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code) "
- "values (%s, %s, %s, 'Demo private post', 'A synthetic private post scoped to Demo Corp accounts.', 'vom', 'private')",
+ "insert into source_post (author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, voc_type_code, visibility_code, created_at) "
+ "values (%s, %s, %s, 'Demo private post', 'A synthetic private post scoped to Demo Corp accounts.', 'vom', 'private', '2026-01-10T12:00:00Z')",
(account_ids["demo.admin"], corporate_entity_id, process_units["DEMO-PU-HQ"]),
)
+ cur.execute(
+ "update source_post set created_at = '2026-01-10T12:00:00Z' "
+ "where post_title in ('Demo public post', 'Demo private post') "
+ "and created_at > '2026-01-12T12:00:00Z'"
+ )
cur.execute("select post_id from source_post where post_title = 'Demo public post'")
demo_public_post_id = cur.fetchone()[0]
cur.execute(
@@ -251,8 +272,9 @@ def seed(
from lineageweave.knowledge_graph import knowledge_graph_edges_for_post
cur.execute(
- "insert into cataloged_person (person_name, person_side_code) values "
- "('Ada West', 'our_side'), ('Priya Nair', 'counterparty') "
+ "insert into cataloged_person (person_name, person_side_code, last_known_job_title) values "
+ "('Ada West', 'our_side', 'Account manager'), "
+ "('Priya Nair', 'counterparty', 'Procurement lead') "
"returning person_name, person_id"
)
people = dict(cur.fetchall())
@@ -290,6 +312,8 @@ def seed(
),
)
+ _seed_demo_public_summary(cur, demo_public_post_id)
+
_seed_reconstructed_lineage(
cur,
account_ids["demo.analyst"],
@@ -302,10 +326,10 @@ def seed(
corporate_entity_id,
process_units["DEMO-PU-LINEAGE"],
)
+ _seed_fixture_keymen_and_voc(cur, corporate_entity_id)
_seed_fixture_summaries(cur)
_seed_fixture_chats(cur)
_seed_fixture_evaluations(cur)
- _seed_fixture_keymen_and_voc(cur, corporate_entity_id)
_seed_fixture_tickets(cur)
_seed_fixture_ticket_activity(cur, account_ids["demo.analyst"], valkey_url)
_seed_demo_period_report(
@@ -314,6 +338,16 @@ def seed(
corporate_entity_id,
process_units["DEMO-PU-LINEAGE"],
)
+ _seed_demo_analysis_run(
+ cur,
+ account_ids["demo.analyst"],
+ corporate_entity_id,
+ )
+ _seed_demo_tepp_run(
+ cur,
+ account_ids["demo.analyst"],
+ corporate_entity_id,
+ )
conn.commit()
finally:
@@ -392,6 +426,7 @@ def _seed_reconstructed_lineage(cur, author_account_id, corporate_entity_id, pro
def _write_post_summary(cur, post_id, summary) -> None:
"""Replace the stored summary for ``post_id`` (idempotent re-seed)."""
+ cur.execute("delete from post_summary_person_mention where post_id = %s", (post_id,))
cur.execute("delete from post_summary_result where post_id = %s", (post_id,))
cur.execute(
"insert into post_summary_result (post_id, korean_summary) values (%s, %s)",
@@ -404,10 +439,37 @@ def _write_post_summary(cur, post_id, summary) -> None:
)
for role in summary.roles_and_responsibilities:
cur.execute(
- "insert into post_summary_role (post_id, person_name, responsibility) values (%s, %s, %s)",
- (post_id, role.person_name, role.responsibility),
+ "insert into post_summary_role "
+ "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name) "
+ "values (%s, %s, %s, %s, %s)",
+ (
+ post_id,
+ role.actor_name,
+ role.responsibility,
+ role.actor_type_code,
+ role.affiliated_organization_name,
+ ),
)
+ cur.execute(
+ """
+ insert into post_summary_person_mention (post_id, person_id)
+ select distinct role.post_id, matched_person.person_id
+ from post_summary_role role
+ join lateral (
+ select person.person_id
+ from cataloged_person person
+ where person.person_name = role.actor_name
+ order by person.created_at, person.person_id
+ limit 1
+ ) matched_person on true
+ where role.post_id = %s
+ and role.actor_type_code = 'prov_person'
+ on conflict do nothing
+ """,
+ (post_id,),
+ )
+
def _write_post_chat(cur, post_id, question: str, chat) -> None:
"""Replace the stored Ask exchange for ``(post_id, question)``."""
@@ -572,22 +634,27 @@ def _seed_fixture_evaluations(cur) -> None:
def _ensure_demo_people(cur, corporate_entity_id) -> dict[str, str]:
"""Ada West / Priya Nair / Jordan Hale plus their affiliations. Idempotent."""
people: dict[str, str] = {}
- for name, side in (
- ("Ada West", "our_side"),
- ("Priya Nair", "counterparty"),
- ("Jordan Hale", "our_side"),
+ for name, side, title in (
+ ("Ada West", "our_side", "Account manager"),
+ ("Priya Nair", "counterparty", "Procurement lead"),
+ ("Jordan Hale", "our_side", "Bid coordinator"),
):
cur.execute("select person_id from cataloged_person where person_name = %s", (name,))
row = cur.fetchone()
if row is None:
cur.execute(
- "insert into cataloged_person (person_name, person_side_code) "
- "values (%s, %s) returning person_id",
- (name, side),
+ "insert into cataloged_person (person_name, person_side_code, last_known_job_title) "
+ "values (%s, %s, %s) returning person_id",
+ (name, side, title),
)
people[name] = str(cur.fetchone()[0])
else:
people[name] = str(row[0])
+ cur.execute(
+ "update cataloged_person set last_known_job_title = coalesce(last_known_job_title, %s) "
+ "where person_id = %s",
+ (title, people[name]),
+ )
cur.execute(
"insert into person_affiliation "
"(person_id, affiliated_organization_name, affiliated_corporate_entity_id) "
@@ -1151,14 +1218,249 @@ def _seed_demo_period_report(cur, author_account_id, corporate_entity_id, proces
_persist_seed_period_report(cur, "process_unit", high_key, w03, week3[high_key])
+def demo_source_snapshot_sha256() -> str:
+ """Return the reusable Demo Corp snapshot digest (never a source row)."""
+ return hashlib.sha256(DEMO_SOURCE_SNAPSHOT_MATERIAL).hexdigest()
+
+
+def _ensure_demo_source_snapshot(cur):
+ """Return the shared Demo Corp capture, inserting it on first seed.
+
+ Lineage and TEPP runs share this snapshot (ADR 0013: one capture,
+ many runs). The digest is a hash of a fixed demo contract string --
+ never a source row or DSN.
+ """
+ digest = demo_source_snapshot_sha256()
+ cur.execute(
+ "select analysis_source_snapshot_id from analysis_source_snapshot "
+ "where snapshot_sha256 = %s",
+ (digest,),
+ )
+ snapshot_row = cur.fetchone()
+ if snapshot_row is not None:
+ return snapshot_row[0]
+ cur.execute(
+ """
+ insert into analysis_source_snapshot
+ (snapshot_sha256, source_contract_version,
+ maximum_available_time, captured_at)
+ values (%s, %s,
+ '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z')
+ returning analysis_source_snapshot_id
+ """,
+ (digest, DEMO_SOURCE_CONTRACT_VERSION),
+ )
+ return cur.fetchone()[0]
+
+
+def _ensure_demo_source_counts(cur, snapshot_id) -> None:
+ """Insert demo counts only when the snapshot still has none.
+
+ ``enforce_analysis_source_count_freeze`` runs BEFORE INSERT. After
+ the first run points at the snapshot, a later ``INSERT ... ON
+ CONFLICT DO NOTHING`` still raises ``analysis_source_count_frozen_after_run``
+ and rolls back the whole ``seed()`` transaction. Skip when counts
+ already exist so ``make seed`` can be re-run.
+ """
+ cur.execute(
+ "select 1 from analysis_source_count "
+ "where analysis_source_snapshot_id = %s limit 1",
+ (snapshot_id,),
+ )
+ if cur.fetchone() is not None:
+ return
+ cur.execute(
+ """
+ insert into analysis_source_count
+ (analysis_source_snapshot_id, count_type_code, count_value)
+ values
+ (%s, 'analysis_count_document', 3),
+ (%s, 'analysis_count_thread', 1),
+ (%s, 'analysis_count_lineage_node', 5),
+ (%s, 'analysis_count_lineage_edge', 4)
+ """,
+ (snapshot_id, snapshot_id, snapshot_id, snapshot_id),
+ )
+
+
+def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None:
+ """Insert one Demo-Corp lineage run so Analysis runs is not empty.
+
+ Aggregates only: three synthetic documents, one thread. Reuses the
+ shared Demo Corp snapshot so a later TEPP run can attach to the
+ same capture.
+ """
+ snapshot_id = _ensure_demo_source_snapshot(cur)
+ _ensure_demo_source_counts(cur, snapshot_id)
+ cur.execute(
+ """
+ select analysis_run_id from analysis_run
+ where requested_by_account_id = %s
+ and idempotency_key = %s
+ """,
+ (requested_by_account_id, DEMO_LINEAGE_IDEMPOTENCY_KEY),
+ )
+ run_row = cur.fetchone()
+ if run_row is None:
+ cur.execute(
+ """
+ insert into analysis_run
+ (analysis_source_snapshot_id, run_kind_code, idempotency_key,
+ requested_by_account_id, knowledge_cutoff,
+ configuration_schema_version, configuration_sha256,
+ code_revision_sha, requested_at)
+ values (%s, 'analysis_run_lineage', %s,
+ %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s,
+ '2026-01-12T12:30:00Z')
+ returning analysis_run_id
+ """,
+ (
+ snapshot_id,
+ DEMO_LINEAGE_IDEMPOTENCY_KEY,
+ requested_by_account_id,
+ "b" * 64,
+ "c" * 40,
+ ),
+ )
+ run_id = cur.fetchone()[0]
+ else:
+ run_id = run_row[0]
+ cur.execute(
+ """
+ insert into analysis_run_scope
+ (analysis_run_id, scope_kind_code, corporate_entity_id)
+ values (%s, 'analysis_scope_corporate_entity', %s)
+ on conflict (analysis_run_id) do nothing
+ """,
+ (run_id, corporate_entity_id),
+ )
+ for ordinal, status, occurred in (
+ (1, "analysis_status_pending", "2026-01-12T12:31:00Z"),
+ (2, "analysis_status_running", "2026-01-12T12:32:00Z"),
+ (3, "analysis_status_succeeded", "2026-01-12T12:33:00Z"),
+ ):
+ cur.execute(
+ """
+ insert into analysis_run_status_event
+ (analysis_run_id, status_ordinal, status_code, occurred_at)
+ values (%s, %s, %s, %s)
+ on conflict do nothing
+ """,
+ (run_id, ordinal, status, occurred),
+ )
+
+
+def tepp_seed_request() -> AnalysisRunRequest:
+ """Build the Demo Corp TEPP request against the shared snapshot digest."""
+ return AnalysisRunRequest(
+ idempotency_key=DEMO_TEPP_IDEMPOTENCY_KEY,
+ tenant_workspace_id="demo-workspace",
+ snapshot_id=demo_source_snapshot_sha256(),
+ knowledge_cutoff="2026-01-12T12:00:00Z",
+ model_contract_version="tepp-analysis-run-v1",
+ output_profile="calibrated_event_measurement",
+ )
+
+
+def tepp_seed_outcome(client: TeppClient | None = None) -> tuple[str, str | None]:
+ """Ask TEPP through the published client. A missing transport is Failed.
+
+ Never invents a psychometric score. ``tepp_not_available`` means the
+ channel was dropped, not a calibrated negative result. A live
+ envelope is also not a persistable measurement in this seed, so the
+ run is not stamped Succeeded.
+ """
+ request = tepp_seed_request()
+ try:
+ (client or TeppClient()).submit_analysis_run(request)
+ except TeppNotAvailable:
+ return "analysis_status_failed", "tepp_not_available"
+ return "analysis_status_failed", "tepp_result_not_persisted"
+
+
+def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> None:
+ """Insert one Demo-Corp TEPP run so the kind is visible without a live TEPP.
+
+ Uses :func:`tepp_seed_outcome` against the shared lineage snapshot.
+ Default transport is unavailable, so the run ends Failed /
+ ``tepp_not_available`` -- never a fake theta.
+ """
+ snapshot_id = _ensure_demo_source_snapshot(cur)
+ _ensure_demo_source_counts(cur, snapshot_id)
+ cur.execute(
+ """
+ select analysis_run_id from analysis_run
+ where requested_by_account_id = %s
+ and idempotency_key = %s
+ """,
+ (requested_by_account_id, DEMO_TEPP_IDEMPOTENCY_KEY),
+ )
+ run_row = cur.fetchone()
+ if run_row is None:
+ cur.execute(
+ """
+ insert into analysis_run
+ (analysis_source_snapshot_id, run_kind_code, idempotency_key,
+ requested_by_account_id, knowledge_cutoff,
+ configuration_schema_version, configuration_sha256,
+ code_revision_sha, requested_at)
+ values (%s, 'analysis_run_tepp', %s,
+ %s, '2026-01-12T12:00:00Z', 'tepp-run-v1', %s, %s,
+ '2026-01-12T12:34:00Z')
+ returning analysis_run_id
+ """,
+ (
+ snapshot_id,
+ DEMO_TEPP_IDEMPOTENCY_KEY,
+ requested_by_account_id,
+ "d" * 64,
+ "e" * 40,
+ ),
+ )
+ run_id = cur.fetchone()[0]
+ else:
+ run_id = run_row[0]
+ cur.execute(
+ """
+ insert into analysis_run_scope
+ (analysis_run_id, scope_kind_code, corporate_entity_id)
+ values (%s, 'analysis_scope_corporate_entity', %s)
+ on conflict (analysis_run_id) do nothing
+ """,
+ (run_id, corporate_entity_id),
+ )
+ final_status, failure_code = tepp_seed_outcome()
+ events = [
+ (1, "analysis_status_pending", "2026-01-12T12:35:00Z", None),
+ (2, "analysis_status_running", "2026-01-12T12:36:00Z", None),
+ (3, final_status, "2026-01-12T12:37:00Z", failure_code),
+ ]
+ for ordinal, status, occurred, fail in events:
+ cur.execute(
+ """
+ insert into analysis_run_status_event
+ (analysis_run_id, status_ordinal, status_code, occurred_at, failure_code)
+ values (%s, %s, %s, %s, %s)
+ on conflict do nothing
+ """,
+ (run_id, ordinal, status, occurred, fail),
+ )
+
+
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--postgres-dsn", default=DEFAULT_POSTGRES_DSN)
parser.add_argument("--keycloak-base-url", default=DEFAULT_KEYCLOAK_BASE_URL)
parser.add_argument("--keycloak-admin-user", default=DEFAULT_KEYCLOAK_ADMIN_USER)
- parser.add_argument("--keycloak-admin-password", default=DEFAULT_KEYCLOAK_ADMIN_PASSWORD)
+ parser.add_argument(
+ "--keycloak-admin-password",
+ default=os.environ.get("KEYCLOAK_ADMIN_PASSWORD"),
+ help="Keycloak master admin password (or KEYCLOAK_ADMIN_PASSWORD). Required.",
+ )
parser.add_argument("--valkey-url", default=DEFAULT_VALKEY_URL)
args = parser.parse_args()
+ if not args.keycloak_admin_password:
+ parser.error("set KEYCLOAK_ADMIN_PASSWORD or pass --keycloak-admin-password")
subjects = _fetch_demo_user_subjects(args.keycloak_base_url, args.keycloak_admin_user, args.keycloak_admin_password)
seed(args.postgres_dsn, subjects, args.valkey_url)
diff --git a/tests/test_analysis_run_authorization.py b/tests/test_analysis_run_authorization.py
new file mode 100644
index 000000000..64a0504f7
--- /dev/null
+++ b/tests/test_analysis_run_authorization.py
@@ -0,0 +1,256 @@
+"""SQL authorization for the Milestone 2 analysis-run read projection."""
+
+from __future__ import annotations
+
+import os
+import uuid
+from pathlib import Path
+from urllib.parse import urlsplit, urlunsplit
+
+import psycopg2
+import pytest
+from psycopg2 import sql
+
+_ROOT = Path(__file__).resolve().parents[1]
+_INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql"
+_REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql"
+_RETENTION_MIGRATION = _ROOT / "migrations" / "0020_analysis_run_retention_purge.sql"
+_ADMIN_DSN = os.environ.get(
+ "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres"
+)
+
+
+def _postgres_available() -> bool:
+ """Return whether the configured administrator DSN is reachable."""
+ try:
+ psycopg2.connect(_ADMIN_DSN, connect_timeout=2).close()
+ return True
+ except psycopg2.OperationalError:
+ return False
+
+
+def _database_dsn(database_name: str) -> str:
+ """Replace only the database path while preserving DSN query options."""
+ parsed = urlsplit(_ADMIN_DSN)
+ return urlunsplit(parsed._replace(path=f"/{database_name}"))
+
+
+@pytest.fixture
+def authz_db():
+ """Yield a throwaway database migrated through the registry schema."""
+ if not _postgres_available():
+ pytest.skip("a reachable PostgreSQL administrator DSN is required")
+ database_name = f"lineageweave_authz_{uuid.uuid4().hex[:12]}"
+ admin_connection = psycopg2.connect(_ADMIN_DSN)
+ admin_connection.autocommit = True
+ with admin_connection.cursor() as cursor:
+ cursor.execute(
+ sql.SQL("create database {}").format(sql.Identifier(database_name))
+ )
+ try:
+ connection = psycopg2.connect(_database_dsn(database_name))
+ try:
+ connection.autocommit = True
+ with connection.cursor() as cursor:
+ cursor.execute(_INITIAL_MIGRATION.read_text(encoding="utf-8"))
+ cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8"))
+ cursor.execute(_RETENTION_MIGRATION.read_text(encoding="utf-8"))
+ yield connection
+ finally:
+ connection.close()
+ finally:
+ with admin_connection.cursor() as cursor:
+ cursor.execute(
+ sql.SQL("drop database {}").format(sql.Identifier(database_name))
+ )
+ admin_connection.close()
+
+
+def _insert_account(cursor, label: str) -> str:
+ """Insert one synthetic authenticated account and return its UUID."""
+ suffix = uuid.uuid4().hex
+ cursor.execute(
+ """
+ insert into user_account
+ (external_subject_id, display_name, email_address)
+ values (%s, %s, %s)
+ returning user_account_id
+ """,
+ (f"{label}-{suffix}", f"{label.title()} User", f"{label}-{suffix}@example.test"),
+ )
+ return str(cursor.fetchone()[0])
+
+
+def _insert_corp(cursor, code: str, name: str) -> str:
+ """Insert one synthetic corporate entity."""
+ cursor.execute(
+ """
+ insert into common_lookup_value (lookup_category, lookup_code, lookup_label)
+ values ('corporate_entity_level', 'company', 'Company')
+ on conflict (lookup_code) do nothing
+ """
+ )
+ cursor.execute(
+ """
+ insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code)
+ values (%s, %s, 'company')
+ returning corporate_entity_id
+ """,
+ (code, name),
+ )
+ return str(cursor.fetchone()[0])
+
+
+def _complete_run(
+ cursor,
+ *,
+ account_id: str,
+ digest: str,
+ idempotency_key: str,
+ scope_kind: str,
+ corporate_entity_id: str | None = None,
+) -> str:
+ """Insert one succeeded run with one document-count aggregate."""
+ cursor.execute(
+ """
+ insert into analysis_source_snapshot
+ (snapshot_sha256, source_contract_version,
+ maximum_available_time, captured_at)
+ values (%s, 'source-contract-v1',
+ '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z')
+ returning analysis_source_snapshot_id
+ """,
+ (digest,),
+ )
+ snapshot_id = cursor.fetchone()[0]
+ cursor.execute(
+ """
+ insert into analysis_source_count
+ (analysis_source_snapshot_id, count_type_code, count_value)
+ values (%s, 'analysis_count_document', 3)
+ """,
+ (snapshot_id,),
+ )
+ cursor.execute(
+ """
+ insert into analysis_run
+ (analysis_source_snapshot_id, run_kind_code, idempotency_key,
+ requested_by_account_id, knowledge_cutoff,
+ configuration_schema_version, configuration_sha256,
+ code_revision_sha, requested_at)
+ values (%s, 'analysis_run_lineage', %s, %s,
+ '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s,
+ '2026-01-12T12:30:00Z')
+ returning analysis_run_id
+ """,
+ (snapshot_id, idempotency_key, account_id, "b" * 64, "c" * 40),
+ )
+ run_id = str(cursor.fetchone()[0])
+ if scope_kind == "analysis_scope_corporate_entity":
+ cursor.execute(
+ """
+ insert into analysis_run_scope
+ (analysis_run_id, scope_kind_code, corporate_entity_id)
+ values (%s, %s, %s)
+ """,
+ (run_id, scope_kind, corporate_entity_id),
+ )
+ else:
+ cursor.execute(
+ """
+ insert into analysis_run_scope
+ (analysis_run_id, scope_kind_code)
+ values (%s, %s)
+ """,
+ (run_id, scope_kind),
+ )
+ for ordinal, status, occurred in (
+ (1, "analysis_status_pending", "2026-01-12T12:31:00Z"),
+ (2, "analysis_status_running", "2026-01-12T12:32:00Z"),
+ (3, "analysis_status_succeeded", "2026-01-12T12:33:00Z"),
+ ):
+ cursor.execute(
+ """
+ insert into analysis_run_status_event
+ (analysis_run_id, status_ordinal, status_code, occurred_at)
+ values (%s, %s, %s, %s)
+ """,
+ (run_id, ordinal, status, occurred),
+ )
+ return run_id
+
+
+def _visible_ids(cursor, account_id: str, entity_ids: list[str]) -> set[str]:
+ """Apply the same visibility predicate the product API uses."""
+ cursor.execute(
+ """
+ select run.analysis_run_id
+ from analysis_run run
+ join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id
+ where
+ run.requested_by_account_id = %s
+ or (
+ scope.scope_kind_code = 'analysis_scope_corporate_entity'
+ and scope.corporate_entity_id = any(%s::uuid[])
+ )
+ or (
+ scope.scope_kind_code = 'analysis_scope_process_unit'
+ and exists (
+ select 1 from account_affiliation aff
+ where aff.user_account_id = %s
+ and aff.process_unit_id = scope.process_unit_id
+ )
+ )
+ """,
+ (account_id, entity_ids, account_id),
+ )
+ return {str(row[0]) for row in cursor.fetchall()}
+
+
+def test_hidden_scope_does_not_leak_through_all_visible_or_other_corp(authz_db) -> None:
+ """A Demo-Corp viewer never sees another tenant's run or its aggregates."""
+ with authz_db.cursor() as cursor:
+ viewer = _insert_account(cursor, "viewer")
+ outsider = _insert_account(cursor, "outsider")
+ own_corp = _insert_corp(cursor, "DEMO-CORP-AUTHZ", "Demo Corp")
+ other_corp = _insert_corp(cursor, "OTHER-CORP-AUTHZ", "Other Corp")
+ cursor.execute(
+ """
+ insert into account_affiliation (user_account_id, corporate_entity_id)
+ values (%s, %s)
+ """,
+ (viewer, own_corp),
+ )
+ own_run = _complete_run(
+ cursor,
+ account_id=viewer,
+ digest="a" * 64,
+ idempotency_key="own-corp",
+ scope_kind="analysis_scope_corporate_entity",
+ corporate_entity_id=own_corp,
+ )
+ hidden_all_visible = _complete_run(
+ cursor,
+ account_id=outsider,
+ digest="d" * 64,
+ idempotency_key="hidden-all",
+ scope_kind="analysis_scope_all_visible",
+ )
+ hidden_other_corp = _complete_run(
+ cursor,
+ account_id=outsider,
+ digest="e" * 64,
+ idempotency_key="hidden-other",
+ scope_kind="analysis_scope_corporate_entity",
+ corporate_entity_id=other_corp,
+ )
+
+ visible = _visible_ids(cursor, viewer, [own_corp])
+ assert own_run in visible
+ assert hidden_all_visible not in visible
+ assert hidden_other_corp not in visible
+
+ outsider_visible = _visible_ids(cursor, outsider, [other_corp])
+ assert hidden_all_visible in outsider_visible
+ assert hidden_other_corp in outsider_visible
+ assert own_run not in outsider_visible
diff --git a/tests/test_analysis_run_create.py b/tests/test_analysis_run_create.py
new file mode 100644
index 000000000..4e24a4228
--- /dev/null
+++ b/tests/test_analysis_run_create.py
@@ -0,0 +1,134 @@
+"""Authorized analysis-run create hashes the cutoff bag, never a score."""
+
+from datetime import datetime, timezone
+
+from backend.app.analysis_run_ingestion import (
+ AnalysisRunCreateError,
+ _resolve_corporate_entity_id,
+ plan_analysis_run_capture,
+)
+import pytest
+
+
+_CUTOFF = datetime(2026, 1, 12, 12, 0, tzinfo=timezone.utc)
+_EARLIER = datetime(2026, 1, 10, 9, 0, tzinfo=timezone.utc)
+
+
+def test_capture_digest_is_stable_for_the_same_authorized_bag() -> None:
+ first = plan_analysis_run_capture(
+ run_kind_code="analysis_run_lineage",
+ scope_kind_code="analysis_scope_corporate_entity",
+ corporate_entity_id="corp-1",
+ knowledge_cutoff=_CUTOFF,
+ idempotency_key="client-key-1",
+ post_ids=["post-b", "post-a"],
+ thread_keys=["thread-a", "thread-a"],
+ latest_post_created_at=_EARLIER,
+ )
+ second = plan_analysis_run_capture(
+ run_kind_code="analysis_run_lineage",
+ scope_kind_code="analysis_scope_corporate_entity",
+ corporate_entity_id="corp-1",
+ knowledge_cutoff=_CUTOFF,
+ idempotency_key="client-key-1",
+ post_ids=["post-a", "post-b"],
+ thread_keys=["thread-a", "thread-a"],
+ latest_post_created_at=_EARLIER,
+ )
+ assert first.snapshot_sha256 == second.snapshot_sha256
+ assert first.configuration_sha256 == second.configuration_sha256
+ assert first.document_count == 2
+ assert first.thread_count == 1
+ assert first.maximum_available_time == _EARLIER
+ assert "theta" not in first.snapshot_sha256
+ assert first.configuration_schema_version == "lineage-run-v1"
+
+
+def test_later_cutoff_or_other_kind_does_not_reuse_the_wrong_digest() -> None:
+ lineage = plan_analysis_run_capture(
+ run_kind_code="analysis_run_lineage",
+ scope_kind_code="analysis_scope_corporate_entity",
+ corporate_entity_id="corp-1",
+ knowledge_cutoff=_CUTOFF,
+ idempotency_key="client-key-1",
+ post_ids=["post-a"],
+ thread_keys=["thread-a"],
+ latest_post_created_at=_EARLIER,
+ )
+ later = plan_analysis_run_capture(
+ run_kind_code="analysis_run_lineage",
+ scope_kind_code="analysis_scope_corporate_entity",
+ corporate_entity_id="corp-1",
+ knowledge_cutoff=datetime(2026, 1, 13, 12, 0, tzinfo=timezone.utc),
+ idempotency_key="client-key-1",
+ post_ids=["post-a"],
+ thread_keys=["thread-a"],
+ latest_post_created_at=_EARLIER,
+ )
+ tepp = plan_analysis_run_capture(
+ run_kind_code="analysis_run_tepp",
+ scope_kind_code="analysis_scope_corporate_entity",
+ corporate_entity_id="corp-1",
+ knowledge_cutoff=_CUTOFF,
+ idempotency_key="client-key-1",
+ post_ids=["post-a"],
+ thread_keys=["thread-a"],
+ latest_post_created_at=_EARLIER,
+ )
+ assert lineage.snapshot_sha256 != later.snapshot_sha256
+ assert lineage.snapshot_sha256 == tepp.snapshot_sha256
+ assert lineage.configuration_sha256 != tepp.configuration_sha256
+ assert tepp.configuration_schema_version == "tepp-run-v1"
+
+
+def test_omitted_cutoff_keeps_the_same_client_key_stable() -> None:
+ first = plan_analysis_run_capture(
+ run_kind_code="analysis_run_lineage",
+ scope_kind_code="analysis_scope_corporate_entity",
+ corporate_entity_id="corp-1",
+ knowledge_cutoff=_CUTOFF,
+ idempotency_key="client-key-1",
+ post_ids=["post-a"],
+ thread_keys=["thread-a"],
+ latest_post_created_at=_EARLIER,
+ cutoff_explicit=False,
+ )
+ later_clock = plan_analysis_run_capture(
+ run_kind_code="analysis_run_lineage",
+ scope_kind_code="analysis_scope_corporate_entity",
+ corporate_entity_id="corp-1",
+ knowledge_cutoff=datetime(2026, 1, 13, 12, 0, tzinfo=timezone.utc),
+ idempotency_key="client-key-1",
+ post_ids=["post-a"],
+ thread_keys=["thread-a"],
+ latest_post_created_at=_EARLIER,
+ cutoff_explicit=False,
+ )
+ assert first.configuration_sha256 == later_clock.configuration_sha256
+ assert first.snapshot_sha256 == later_clock.snapshot_sha256
+
+
+def test_empty_corpus_uses_the_cutoff_as_latest_available_time() -> None:
+ capture = plan_analysis_run_capture(
+ run_kind_code="analysis_run_lineage",
+ scope_kind_code="analysis_scope_corporate_entity",
+ corporate_entity_id="corp-1",
+ knowledge_cutoff=_CUTOFF,
+ idempotency_key="client-key-1",
+ post_ids=[],
+ thread_keys=[],
+ latest_post_created_at=None,
+ )
+ assert capture.document_count == 0
+ assert capture.thread_count == 0
+ assert capture.maximum_available_time == _CUTOFF
+
+
+def test_create_rejects_an_unaffiliated_or_ambiguous_corporate_entity() -> None:
+ with pytest.raises(AnalysisRunCreateError) as hidden:
+ _resolve_corporate_entity_id("corp-other", ["corp-1"])
+ assert hidden.value.status_code == 404
+ with pytest.raises(AnalysisRunCreateError) as ambiguous:
+ _resolve_corporate_entity_id(None, ["corp-1", "corp-2"])
+ assert ambiguous.value.status_code == 422
+ assert _resolve_corporate_entity_id(None, ["corp-1"]) == "corp-1"
diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py
new file mode 100644
index 000000000..3d185dbed
--- /dev/null
+++ b/tests/test_analysis_run_registry_schema.py
@@ -0,0 +1,1140 @@
+"""Real-PostgreSQL contracts for the normalized Milestone 2 run registry."""
+
+from __future__ import annotations
+
+import hashlib
+import os
+import re
+import uuid
+from pathlib import Path
+from urllib.parse import urlsplit, urlunsplit
+
+import psycopg2
+import psycopg2.errors
+import pytest
+from psycopg2 import sql
+
+_ROOT = Path(__file__).resolve().parents[1]
+_INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql"
+_REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql"
+_REGISTRY_ROLLBACK = _ROOT / "migrations" / "rollback" / "0018_analysis_run_registry.sql"
+_RETENTION_MIGRATION = _ROOT / "migrations" / "0020_analysis_run_retention_purge.sql"
+_RETENTION_ROLLBACK = (
+ _ROOT / "migrations" / "rollback" / "0020_analysis_run_retention_purge.sql"
+)
+_POSTGRES_IMAGE = _ROOT / "docker" / "postgres-init" / "Dockerfile"
+_ADMIN_DSN = os.environ.get(
+ "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres"
+)
+_REQUIRED_TABLES = {
+ "analysis_source_snapshot",
+ "analysis_source_count",
+ "analysis_run",
+ "analysis_run_scope",
+ "analysis_run_status_event",
+}
+_REQUIRED_LOOKUP_CODES = {
+ "analysis_run_lineage",
+ "analysis_run_report",
+ "analysis_run_tepp",
+ "analysis_status_pending",
+ "analysis_status_running",
+ "analysis_status_succeeded",
+ "analysis_status_failed",
+ "analysis_status_cancelled",
+ "analysis_scope_all_visible",
+ "analysis_scope_corporate_entity",
+ "analysis_scope_process_unit",
+ "analysis_scope_thread_group",
+ "analysis_count_source_row",
+ "analysis_count_document",
+ "analysis_count_thread",
+ "analysis_count_lineage_node",
+ "analysis_count_lineage_edge",
+}
+
+
+def _postgres_available() -> bool:
+ """Return whether the configured administrator DSN is reachable."""
+
+ try:
+ psycopg2.connect(_ADMIN_DSN, connect_timeout=2).close()
+ return True
+ except psycopg2.OperationalError:
+ return False
+
+
+def _database_dsn(database_name: str) -> str:
+ """Replace only the database path while preserving DSN query options."""
+
+ parsed = urlsplit(_ADMIN_DSN)
+ return urlunsplit(parsed._replace(path=f"/{database_name}"))
+
+
+def _table_definition(migration: str, table_name: str) -> str:
+ """Return one table definition from the deterministic migration text."""
+
+ match = re.search(
+ rf"create table if not exists {re.escape(table_name)}\s*\((.*?)\n\);",
+ migration,
+ re.IGNORECASE | re.DOTALL,
+ )
+ assert match is not None, table_name
+ return match.group(1)
+
+
+@pytest.fixture
+def registry_db():
+ """Yield a throwaway database migrated through the registry schema."""
+
+ if not _postgres_available():
+ pytest.skip("a reachable PostgreSQL administrator DSN is required")
+ database_name = f"lineageweave_registry_{uuid.uuid4().hex[:12]}"
+ admin_connection = psycopg2.connect(_ADMIN_DSN)
+ admin_connection.autocommit = True
+ with admin_connection.cursor() as cursor:
+ cursor.execute(
+ sql.SQL("create database {}").format(sql.Identifier(database_name))
+ )
+ try:
+ connection = psycopg2.connect(_database_dsn(database_name))
+ try:
+ connection.autocommit = True
+ with connection.cursor() as cursor:
+ cursor.execute(_INITIAL_MIGRATION.read_text(encoding="utf-8"))
+ cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8"))
+ cursor.execute(_RETENTION_MIGRATION.read_text(encoding="utf-8"))
+ yield connection
+ finally:
+ connection.close()
+ finally:
+ with admin_connection.cursor() as cursor:
+ cursor.execute(
+ sql.SQL("drop database {}").format(sql.Identifier(database_name))
+ )
+ admin_connection.close()
+
+
+def _insert_account(cursor, label: str = "operator") -> str:
+ """Insert one synthetic authenticated account and return its UUID."""
+
+ suffix = uuid.uuid4().hex
+ cursor.execute(
+ """
+ insert into user_account
+ (external_subject_id, display_name, email_address)
+ values (%s, %s, %s)
+ returning user_account_id
+ """,
+ (f"{label}-{suffix}", f"{label.title()} User", f"{label}-{suffix}@example.test"),
+ )
+ return str(cursor.fetchone()[0])
+
+
+def _insert_snapshot(
+ cursor,
+ *,
+ digest: str = "a" * 64,
+ maximum_available_time: str = "2026-08-15T00:00:00Z",
+ captured_at: str = "2026-08-15T00:05:00Z",
+) -> str:
+ """Insert one immutable source snapshot and return its UUID."""
+
+ cursor.execute(
+ """
+ insert into analysis_source_snapshot
+ (snapshot_sha256, source_contract_version,
+ maximum_available_time, captured_at)
+ values (%s, 'source-contract-v1', %s, %s)
+ returning analysis_source_snapshot_id
+ """,
+ (digest, maximum_available_time, captured_at),
+ )
+ return str(cursor.fetchone()[0])
+
+
+def _insert_run(
+ cursor,
+ *,
+ snapshot_id: str,
+ account_id: str,
+ idempotency_key: str,
+ knowledge_cutoff: str = "2026-08-15T00:30:00Z",
+ run_kind_code: str = "analysis_run_lineage",
+ requested_at: str = "2026-08-15T00:45:00Z",
+) -> str:
+ """Insert one immutable account-scoped analysis request."""
+
+ cursor.execute(
+ """
+ insert into analysis_run
+ (analysis_source_snapshot_id, run_kind_code, idempotency_key,
+ requested_by_account_id, knowledge_cutoff,
+ configuration_schema_version, configuration_sha256,
+ code_revision_sha, requested_at)
+ values (%s, %s, %s, %s, %s, 'lineage-run-v1', %s, %s, %s)
+ returning analysis_run_id
+ """,
+ (
+ snapshot_id,
+ run_kind_code,
+ idempotency_key,
+ account_id,
+ knowledge_cutoff,
+ "b" * 64,
+ "c" * 40,
+ requested_at,
+ ),
+ )
+ return str(cursor.fetchone()[0])
+
+
+def _insert_run_bearing_registry(
+ cursor,
+ *,
+ digest: str,
+ idempotency_key: str,
+) -> None:
+ """Insert one snapshot, count, run, scope, and pending event."""
+
+ account_id = _insert_account(cursor)
+ snapshot_id = _insert_snapshot(cursor, digest=digest)
+ cursor.execute(
+ "insert into analysis_source_count values "
+ "(%s, 'analysis_count_document', 3)",
+ (snapshot_id,),
+ )
+ run_id = _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=account_id,
+ idempotency_key=idempotency_key,
+ )
+ cursor.execute(
+ "insert into analysis_run_scope "
+ "(analysis_run_id, scope_kind_code) "
+ "values (%s, 'analysis_scope_all_visible')",
+ (run_id,),
+ )
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 1, 'analysis_status_pending', "
+ "'2026-08-15T01:00:00Z')",
+ (run_id,),
+ )
+
+
+def _authorize_session_for_purge(cursor) -> str:
+ """Grant the current session_user both retention locks and return it."""
+
+ cursor.execute("select session_user")
+ session_role = cursor.fetchone()[0]
+ cursor.execute(
+ "insert into analysis_run_retention_grant (database_role_name) "
+ "select %s "
+ "where not exists ("
+ " select 1 from analysis_run_retention_grant "
+ " where database_role_name = %s and revoked_at is null"
+ ")",
+ (session_role, session_role),
+ )
+ cursor.execute(
+ sql.SQL("grant analysis_run_retention_admin to {}").format(
+ sql.Identifier(session_role)
+ )
+ )
+ return session_role
+
+
+def _drop_role_if_exists(cursor, role_name: str) -> None:
+ """Drop a test role after releasing objects it owns."""
+
+ cursor.execute("select 1 from pg_roles where rolname = %s", (role_name,))
+ if cursor.fetchone() is None:
+ return
+ cursor.execute(sql.SQL("drop owned by {}").format(sql.Identifier(role_name)))
+ cursor.execute(sql.SQL("drop role {}").format(sql.Identifier(role_name)))
+
+
+def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> None:
+ """Static contract rejects the parallel prototype and duplicated clocks."""
+
+ migration = _REGISTRY_MIGRATION.read_text(encoding="utf-8")
+ rollback = _REGISTRY_ROLLBACK.read_text(encoding="utf-8")
+ dockerfile = _POSTGRES_IMAGE.read_text(encoding="utf-8")
+ created_tables = set(
+ re.findall(r"create table if not exists\s+([a-z0-9_]+)", migration, re.I)
+ )
+ assert _REQUIRED_TABLES <= created_tables
+ assert "analysis_run_records" not in created_tables
+ assert "metadata_payload" not in migration
+ assert "jsonb" not in migration.casefold()
+ assert _REQUIRED_LOOKUP_CODES <= set(
+ re.findall(r"'(analysis_[a-z0-9_]+)'", migration)
+ )
+ assert "0018_analysis_run_registry.sql" in dockerfile
+ assert "0019_role_catalog_identity.sql" in dockerfile
+ assert "0020_analysis_run_retention_purge.sql" in dockerfile
+ seed = (_ROOT / "scripts" / "seed_demo_data.py").read_text(encoding="utf-8")
+ assert seed.index("0019_role_catalog_identity.sql") < seed.index(
+ "0020_analysis_run_retention_purge.sql"
+ )
+ assert "analysis_run_registry_not_empty" in rollback
+ retention = _RETENTION_MIGRATION.read_text(encoding="utf-8")
+ retention_rollback = _RETENTION_ROLLBACK.read_text(encoding="utf-8")
+ assert "purge_analysis_run_registry" in retention
+ assert "analysis_run_retention_event" in retention
+ assert "analysis_run_retention_grant" in retention
+ assert "analysis_run_retention_admin" in retention
+ assert "invoking_session_role" in retention
+ assert "invoking_current_role" in retention
+ assert "security definer" in retention.casefold()
+ assert "revoke all" in retention.casefold()
+ assert "from public" in retention.casefold()
+ assert "analysis_run_retention_not_approved" in retention
+ assert "analysis_run_retention_not_granted" in retention
+ assert "analysis_run_retention_not_admin" in retention
+ assert "analysis_run_retention_event_not_empty" in retention_rollback
+ assert "jsonb" not in retention.casefold()
+ for object_name in re.findall(
+ r"create table if not exists\s+([a-z0-9_]+)"
+ r"|create or replace function\s+([a-z0-9_]+)"
+ r"|create role\s+([a-z0-9_]+)",
+ retention,
+ re.I,
+ ):
+ name = object_name[0] or object_name[1] or object_name[2]
+ assert len(name.split("_")) >= 2, name
+
+ snapshot_definition = _table_definition(migration, "analysis_source_snapshot")
+ run_definition = _table_definition(migration, "analysis_run")
+ assert "maximum_available_time" in snapshot_definition
+ assert "knowledge_cutoff" not in snapshot_definition
+ assert "knowledge_cutoff" in run_definition
+ assert "requested_by_account_id uuid not null" in run_definition
+ assert "unique (requested_by_account_id, idempotency_key)" in run_definition
+ assert "enforce_analysis_run_knowledge_cutoff" in migration
+ assert "reject_analysis_source_snapshot_update" in migration
+ assert "reject_analysis_run_mutation" in migration
+ assert "reject_analysis_run_scope_mutation" in migration
+ assert "analysis_run_scope_required" in migration
+ assert "enforce_analysis_source_count_freeze" in migration
+ assert "enforce_analysis_run_status_transition" in migration
+ assert "analysis_run_current_status" in migration
+
+ object_patterns = (
+ r"create table if not exists\s+([a-z0-9_]+)",
+ r"create(?: unique)? index if not exists\s+([a-z0-9_]+)",
+ r"create or replace function\s+([a-z0-9_]+)",
+ r"create trigger\s+([a-z0-9_]+)",
+ r"create or replace view\s+([a-z0-9_]+)",
+ )
+ for pattern in object_patterns:
+ for object_name in re.findall(pattern, migration, re.I):
+ assert len(object_name.split("_")) >= 2, object_name
+
+
+def test_registry_migration_is_idempotent(registry_db) -> None:
+ """Sequential migration replay preserves one object set."""
+
+ with registry_db.cursor() as cursor:
+ cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8"))
+ cursor.execute(_RETENTION_MIGRATION.read_text(encoding="utf-8"))
+ cursor.execute(
+ "select table_name from information_schema.tables "
+ "where table_schema = 'public'"
+ )
+ tables = {row[0] for row in cursor.fetchall()}
+ cursor.execute(
+ "select table_name from information_schema.views "
+ "where table_schema = 'public'"
+ )
+ views = {row[0] for row in cursor.fetchall()}
+ assert _REQUIRED_TABLES <= tables
+ assert "analysis_run_retention_event" in tables
+ assert "analysis_run_retention_grant" in tables
+ assert "analysis_run_current_status" in views
+
+
+def test_registry_persists_scope_counts_and_legal_status_history(registry_db) -> None:
+ """A valid run keeps normalized scope, counts, and current status."""
+
+ with registry_db.cursor() as cursor:
+ account_id = _insert_account(cursor)
+ snapshot_id = _insert_snapshot(cursor)
+ cursor.execute(
+ "insert into analysis_source_count values "
+ "(%s, 'analysis_count_document', 12)",
+ (snapshot_id,),
+ )
+ run_id = _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=account_id,
+ idempotency_key="synthetic-run-1",
+ )
+ cursor.execute(
+ "insert into analysis_run_scope "
+ "(analysis_run_id, scope_kind_code) "
+ "values (%s, 'analysis_scope_all_visible')",
+ (run_id,),
+ )
+ cursor.execute(
+ """
+ insert into analysis_run_status_event
+ (analysis_run_id, status_ordinal, status_code, occurred_at)
+ values
+ (%s, 1, 'analysis_status_pending', '2026-08-15T01:00:01Z'),
+ (%s, 2, 'analysis_status_running', '2026-08-15T01:00:02Z'),
+ (%s, 3, 'analysis_status_succeeded', '2026-08-15T01:00:03Z')
+ """,
+ (run_id, run_id, run_id),
+ )
+ cursor.execute(
+ "select status_code, status_ordinal from analysis_run_current_status "
+ "where analysis_run_id = %s",
+ (run_id,),
+ )
+ assert cursor.fetchone() == ("analysis_status_succeeded", 3)
+
+
+def test_snapshot_supports_multiple_run_owned_cutoffs_and_blocks_future_evidence(
+ registry_db,
+) -> None:
+ """One capture is reusable, but each run must respect its own cutoff."""
+
+ with registry_db.cursor() as cursor:
+ snapshot_id = _insert_snapshot(cursor)
+ first_account_id = _insert_account(cursor, "first")
+ second_account_id = _insert_account(cursor, "second")
+ first_run_id = _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=first_account_id,
+ idempotency_key="cutoff-one",
+ knowledge_cutoff="2026-08-15T00:30:00Z",
+ )
+ second_run_id = _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=second_account_id,
+ idempotency_key="cutoff-two",
+ knowledge_cutoff="2026-08-16T00:00:00Z",
+ requested_at="2026-08-16T00:30:00Z",
+ )
+ assert first_run_id != second_run_id
+ with pytest.raises(psycopg2.errors.RaiseException):
+ _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=first_account_id,
+ idempotency_key="future-leakage",
+ knowledge_cutoff="2026-08-14T23:59:59Z",
+ )
+
+
+def test_snapshot_counts_and_run_request_are_immutable(registry_db) -> None:
+ """Evidence and request configuration freeze before derivation starts."""
+
+ with registry_db.cursor() as cursor:
+ snapshot_id = _insert_snapshot(cursor)
+ cursor.execute(
+ "insert into analysis_source_count values "
+ "(%s, 'analysis_count_document', 12)",
+ (snapshot_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "update analysis_source_snapshot set source_contract_version = 'x' "
+ "where analysis_source_snapshot_id = %s",
+ (snapshot_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "update analysis_source_count set count_value = 13 "
+ "where analysis_source_snapshot_id = %s",
+ (snapshot_id,),
+ )
+ account_id = _insert_account(cursor)
+ run_id = _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=account_id,
+ idempotency_key="freeze-evidence",
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "update analysis_run set knowledge_cutoff = now() "
+ "where analysis_run_id = %s",
+ (run_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "insert into analysis_source_count values "
+ "(%s, 'analysis_count_thread', 8)",
+ (snapshot_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "delete from analysis_source_count "
+ "where analysis_source_snapshot_id = %s",
+ (snapshot_id,),
+ )
+
+
+def test_idempotency_is_scoped_to_the_authenticated_account(registry_db) -> None:
+ """Two actors may use one opaque key; one actor may not reuse it."""
+
+ with registry_db.cursor() as cursor:
+ snapshot_id = _insert_snapshot(cursor)
+ first_account_id = _insert_account(cursor, "first")
+ second_account_id = _insert_account(cursor, "second")
+ _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=first_account_id,
+ idempotency_key="shared-key",
+ )
+ _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=second_account_id,
+ idempotency_key="shared-key",
+ )
+ with pytest.raises(psycopg2.errors.UniqueViolation):
+ _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=first_account_id,
+ idempotency_key="shared-key",
+ )
+
+
+def test_registry_rejects_invalid_evidence_and_missing_actor(registry_db) -> None:
+ """Database constraints reject malformed audit evidence before persistence."""
+
+ with registry_db.cursor() as cursor:
+ with pytest.raises(psycopg2.errors.CheckViolation):
+ cursor.execute(
+ "insert into analysis_source_snapshot "
+ "(snapshot_sha256, source_contract_version, "
+ "maximum_available_time, captured_at) "
+ "values ('bad', 'source-contract-v1', now(), now())"
+ )
+ snapshot_id = _insert_snapshot(cursor)
+ with pytest.raises(psycopg2.errors.CheckViolation):
+ cursor.execute(
+ "insert into analysis_source_count values "
+ "(%s, 'analysis_count_source_row', -1)",
+ (snapshot_id,),
+ )
+ with pytest.raises(psycopg2.errors.NotNullViolation):
+ cursor.execute(
+ """
+ insert into analysis_run
+ (analysis_source_snapshot_id, run_kind_code, idempotency_key,
+ knowledge_cutoff, configuration_schema_version,
+ configuration_sha256, code_revision_sha)
+ values (%s, 'analysis_run_report', 'missing-actor', now(),
+ 'report-run-v1', %s, %s)
+ """,
+ (snapshot_id, "d" * 64, "e" * 40),
+ )
+
+
+def test_status_history_enforces_shape_order_time_and_legal_transitions(
+ registry_db,
+) -> None:
+ """Append-only status evidence is a serialized state machine."""
+
+ with registry_db.cursor() as cursor:
+ snapshot_id = _insert_snapshot(cursor)
+ account_id = _insert_account(cursor)
+ first_run_id = _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=account_id,
+ idempotency_key="first-status",
+ )
+ cursor.execute(
+ "insert into analysis_run_scope "
+ "(analysis_run_id, scope_kind_code) "
+ "values (%s, 'analysis_scope_all_visible')",
+ (first_run_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 1, 'analysis_status_running', now())",
+ (first_run_id,),
+ )
+
+ second_run_id = _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=account_id,
+ idempotency_key="second-status",
+ )
+ cursor.execute(
+ "insert into analysis_run_scope "
+ "(analysis_run_id, scope_kind_code) "
+ "values (%s, 'analysis_scope_all_visible')",
+ (second_run_id,),
+ )
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 1, 'analysis_status_pending', "
+ "'2026-08-15T01:00:00Z')",
+ (second_run_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 3, 'analysis_status_running', "
+ "'2026-08-15T01:00:01Z')",
+ (second_run_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 2, 'analysis_status_succeeded', "
+ "'2026-08-15T01:00:01Z')",
+ (second_run_id,),
+ )
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 2, 'analysis_status_running', "
+ "'2026-08-15T01:00:02Z')",
+ (second_run_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 3, 'analysis_status_succeeded', "
+ "'2026-08-15T01:00:01Z')",
+ (second_run_id,),
+ )
+ with pytest.raises(psycopg2.errors.CheckViolation):
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 3, 'analysis_status_failed', "
+ "'2026-08-15T01:00:03Z')",
+ (second_run_id,),
+ )
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 3, 'analysis_status_succeeded', "
+ "'2026-08-15T01:00:03Z')",
+ (second_run_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 4, 'analysis_status_running', "
+ "'2026-08-15T01:00:04Z')",
+ (second_run_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "update analysis_run_status_event set retryable = true "
+ "where analysis_run_id = %s and status_ordinal = 3",
+ (second_run_id,),
+ )
+
+
+
+def test_run_scope_and_request_evidence_are_immutable(registry_db) -> None:
+ """Authorization scope and request identity cannot be rewritten or erased."""
+
+ with registry_db.cursor() as cursor:
+ snapshot_id = _insert_snapshot(cursor)
+ account_id = _insert_account(cursor)
+ run_id = _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=account_id,
+ idempotency_key="immutable-run",
+ )
+ cursor.execute(
+ "insert into analysis_run_scope "
+ "(analysis_run_id, scope_kind_code) "
+ "values (%s, 'analysis_scope_all_visible')",
+ (run_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "update analysis_run_scope set scope_kind_code = scope_kind_code "
+ "where analysis_run_id = %s",
+ (run_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "delete from analysis_run_scope where analysis_run_id = %s",
+ (run_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "delete from analysis_run where analysis_run_id = %s",
+ (run_id,),
+ )
+
+
+def test_status_requires_scope_and_cannot_predate_request(registry_db) -> None:
+ """Lifecycle evidence starts only after an immutable authorized request."""
+
+ with registry_db.cursor() as cursor:
+ snapshot_id = _insert_snapshot(cursor)
+ account_id = _insert_account(cursor)
+ run_id = _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=account_id,
+ idempotency_key="scoped-status",
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 1, 'analysis_status_pending', "
+ "'2026-08-15T01:00:00Z')",
+ (run_id,),
+ )
+ cursor.execute(
+ "insert into analysis_run_scope "
+ "(analysis_run_id, scope_kind_code) "
+ "values (%s, 'analysis_scope_all_visible')",
+ (run_id,),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 1, 'analysis_status_pending', "
+ "'2026-08-15T00:44:59Z')",
+ (run_id,),
+ )
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at, recorded_at) "
+ "values (%s, 1, 'analysis_status_pending', "
+ "'2026-08-15T01:00:00Z', '2099-01-01T00:00:00Z') "
+ "returning recorded_at",
+ (run_id,),
+ )
+ recorded_at = cursor.fetchone()[0]
+ assert recorded_at.year < 2099
+
+
+def test_machine_codes_and_canonical_idempotency_are_fail_closed(registry_db) -> None:
+ """Audit identifiers are canonical and failure details stay machine-safe."""
+
+ with registry_db.cursor() as cursor:
+ snapshot_id = _insert_snapshot(cursor)
+ account_id = _insert_account(cursor)
+ with pytest.raises(psycopg2.errors.RaiseException):
+ _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=account_id,
+ idempotency_key="future-request",
+ requested_at="2099-01-01T00:00:00Z",
+ )
+ with pytest.raises(psycopg2.errors.CheckViolation):
+ _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=account_id,
+ idempotency_key=" padded-key ",
+ )
+ run_id = _insert_run(
+ cursor,
+ snapshot_id=snapshot_id,
+ account_id=account_id,
+ idempotency_key="machine-safe",
+ )
+ cursor.execute(
+ "insert into analysis_run_scope "
+ "(analysis_run_id, scope_kind_code) "
+ "values (%s, 'analysis_scope_all_visible')",
+ (run_id,),
+ )
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 1, 'analysis_status_pending', "
+ "'2026-08-15T01:00:00Z')",
+ (run_id,),
+ )
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at) "
+ "values (%s, 2, 'analysis_status_running', "
+ "'2026-08-15T01:00:00Z')",
+ (run_id,),
+ )
+ with pytest.raises(psycopg2.errors.CheckViolation):
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at, "
+ "failure_code, retryable) "
+ "values (%s, 3, 'analysis_status_failed', "
+ "'2026-08-15T01:00:00Z', 'provider timeout', true)",
+ (run_id,),
+ )
+ cursor.execute(
+ "insert into analysis_run_status_event "
+ "(analysis_run_id, status_ordinal, status_code, occurred_at, "
+ "failure_code, retryable) "
+ "values (%s, 3, 'analysis_status_failed', "
+ "'2026-08-15T01:00:00Z', 'provider_timeout', true)",
+ (run_id,),
+ )
+
+def test_rollback_refuses_data_loss_then_removes_an_empty_registry(registry_db) -> None:
+ """Downgrade fails closed until audit evidence is explicitly removed."""
+
+ rollback_sql = _REGISTRY_ROLLBACK.read_text(encoding="utf-8")
+ with registry_db.cursor() as cursor:
+ snapshot_id = _insert_snapshot(cursor)
+ with pytest.raises(psycopg2.errors.RaiseException):
+ cursor.execute(rollback_sql)
+ # The rollback script opens an explicit transaction on this
+ # autocommit connection. A RAISE leaves that transaction aborted, and
+ # connection.rollback() is a no-op while autocommit is true.
+ cursor.execute("rollback")
+ with registry_db.cursor() as cursor:
+ cursor.execute(
+ "delete from analysis_source_snapshot "
+ "where analysis_source_snapshot_id = %s",
+ (snapshot_id,),
+ )
+ cursor.execute(rollback_sql)
+ cursor.execute("select to_regclass('public.analysis_run')")
+ assert cursor.fetchone()[0] is None
+ cursor.execute(rollback_sql)
+
+
+def test_approved_retention_purge_empties_a_run_bearing_registry(registry_db) -> None:
+ """Grant plus admin empties expired registry rows; a raw DELETE still fails."""
+
+ rollback_sql = _REGISTRY_ROLLBACK.read_text(encoding="utf-8")
+ retention_rollback = _RETENTION_ROLLBACK.read_text(encoding="utf-8")
+ with registry_db.cursor() as cursor:
+ _insert_run_bearing_registry(
+ cursor,
+ digest="a" * 64,
+ idempotency_key="retention-purge",
+ )
+ cursor.execute("select analysis_run_id from analysis_run")
+ run_id = cursor.fetchone()[0]
+ with pytest.raises(
+ psycopg2.errors.RaiseException,
+ match="analysis_run_request_is_immutable",
+ ):
+ cursor.execute(
+ "delete from analysis_run where analysis_run_id = %s",
+ (run_id,),
+ )
+ with pytest.raises(
+ psycopg2.errors.RaiseException,
+ match="analysis_run_registry_not_empty",
+ ):
+ cursor.execute(rollback_sql)
+ cursor.execute("rollback")
+ session_role = _authorize_session_for_purge(cursor)
+ with pytest.raises(
+ psycopg2.errors.RaiseException,
+ match="analysis_run_retention_not_approved",
+ ):
+ cursor.execute("select purge_analysis_run_registry(%s)", ("wrong-token",))
+ cursor.execute(
+ "select purge_analysis_run_registry(%s)",
+ ("approved-retention-purge",),
+ )
+ cursor.execute("select count(*) from analysis_run")
+ assert cursor.fetchone()[0] == 0
+ cursor.execute("select count(*) from analysis_source_snapshot")
+ assert cursor.fetchone()[0] == 0
+ cursor.execute(
+ "select purged_run_count, purged_snapshot_count, "
+ "approval_token_digest, invoking_session_role, "
+ "invoking_current_role from analysis_run_retention_event"
+ )
+ (
+ purged_run_count,
+ purged_snapshot_count,
+ token_digest,
+ invoking_session_role,
+ invoking_current_role,
+ ) = cursor.fetchone()
+ assert purged_run_count == 1
+ assert purged_snapshot_count == 1
+ assert token_digest == hashlib.sha256(
+ b"approved-retention-purge"
+ ).hexdigest()
+ assert invoking_session_role == session_role
+ assert invoking_current_role
+ cursor.execute(rollback_sql)
+ cursor.execute("select to_regclass('public.analysis_run')")
+ assert cursor.fetchone()[0] is None
+ with pytest.raises(
+ psycopg2.errors.RaiseException,
+ match="analysis_run_retention_event_not_empty",
+ ):
+ cursor.execute(retention_rollback)
+ cursor.execute("rollback")
+ cursor.execute("delete from analysis_run_retention_event")
+ cursor.execute(retention_rollback)
+ cursor.execute(
+ "select to_regclass('public.analysis_run_retention_event')"
+ )
+ assert cursor.fetchone()[0] is None
+
+
+def test_retention_purge_requires_unrevoked_session_grant(registry_db) -> None:
+ """Admin membership plus the published token cannot purge without a grant."""
+
+ role_name = f"retention_denied_{uuid.uuid4().hex[:8]}"
+ with registry_db.cursor() as cursor:
+ cursor.execute(
+ "select has_function_privilege(%s, %s, 'execute')",
+ ("public", "purge_analysis_run_registry(text)"),
+ )
+ assert cursor.fetchone()[0] is False
+ _insert_run_bearing_registry(
+ cursor,
+ digest="d" * 64,
+ idempotency_key="retention-grant-deny",
+ )
+ cursor.execute(
+ sql.SQL("create role {} nologin nosuperuser inherit").format(
+ sql.Identifier(role_name)
+ )
+ )
+ cursor.execute(
+ sql.SQL("grant analysis_run_retention_admin to {}").format(
+ sql.Identifier(role_name)
+ )
+ )
+ try:
+ cursor.execute(
+ sql.SQL("set session authorization {}").format(
+ sql.Identifier(role_name)
+ )
+ )
+ except psycopg2.errors.InsufficientPrivilege:
+ cursor.execute("reset session authorization")
+ _drop_role_if_exists(cursor, role_name)
+ pytest.skip("session authorization requires superuser")
+ with pytest.raises(
+ psycopg2.errors.RaiseException,
+ match="analysis_run_retention_not_granted",
+ ):
+ cursor.execute(
+ "select purge_analysis_run_registry(%s)",
+ ("approved-retention-purge",),
+ )
+ cursor.execute("reset session authorization")
+ cursor.execute(
+ "insert into analysis_run_retention_grant (database_role_name) "
+ "values (%s)",
+ (role_name,),
+ )
+ cursor.execute(
+ sql.SQL("set session authorization {}").format(
+ sql.Identifier(role_name)
+ )
+ )
+ cursor.execute(
+ "select purge_analysis_run_registry(%s)",
+ ("approved-retention-purge",),
+ )
+ cursor.execute("reset session authorization")
+ cursor.execute(
+ "select invoking_session_role from analysis_run_retention_event"
+ )
+ assert cursor.fetchone()[0] == role_name
+ cursor.execute(
+ "update analysis_run_retention_grant "
+ "set revoked_at = clock_timestamp() "
+ "where database_role_name = %s and revoked_at is null",
+ (role_name,),
+ )
+ _insert_run_bearing_registry(
+ cursor,
+ digest="e" * 64,
+ idempotency_key="retention-grant-revoked",
+ )
+ cursor.execute(
+ sql.SQL("set session authorization {}").format(
+ sql.Identifier(role_name)
+ )
+ )
+ with pytest.raises(
+ psycopg2.errors.RaiseException,
+ match="analysis_run_retention_not_granted",
+ ):
+ cursor.execute(
+ "select purge_analysis_run_registry(%s)",
+ ("approved-retention-purge",),
+ )
+ cursor.execute("reset session authorization")
+ _drop_role_if_exists(cursor, role_name)
+
+
+def test_runtime_role_cannot_purge_with_only_the_public_token(registry_db) -> None:
+ """Table DML plus the documented phrase is not a retention grant."""
+
+ runtime_role = f"analysis_run_app_{uuid.uuid4().hex[:12]}"
+ operator_role = f"analysis_run_operator_{uuid.uuid4().hex[:12]}"
+ try:
+ with registry_db.cursor() as cursor:
+ _insert_run_bearing_registry(
+ cursor,
+ digest="f" * 64,
+ idempotency_key="runtime-denied-purge",
+ )
+ cursor.execute("select analysis_run_id from analysis_run")
+ run_id = cursor.fetchone()[0]
+ cursor.execute(
+ sql.SQL(
+ "create role {} nologin nosuperuser inherit"
+ ).format(sql.Identifier(runtime_role))
+ )
+ cursor.execute(
+ sql.SQL(
+ "create role {} nologin nosuperuser inherit"
+ ).format(sql.Identifier(operator_role))
+ )
+ cursor.execute(
+ sql.SQL("grant usage on schema public to {}, {}").format(
+ sql.Identifier(runtime_role),
+ sql.Identifier(operator_role),
+ )
+ )
+ cursor.execute(
+ sql.SQL(
+ "grant select, insert, update, delete on "
+ "analysis_run, analysis_run_scope, "
+ "analysis_run_status_event, analysis_source_snapshot, "
+ "analysis_source_count to {}"
+ ).format(sql.Identifier(runtime_role))
+ )
+ cursor.execute(
+ sql.SQL("grant analysis_run_retention_admin to {}").format(
+ sql.Identifier(operator_role)
+ )
+ )
+ cursor.execute(
+ "insert into analysis_run_retention_grant (database_role_name) "
+ "values (%s)",
+ (operator_role,),
+ )
+ cursor.execute(
+ sql.SQL("set role {}").format(sql.Identifier(runtime_role))
+ )
+ with pytest.raises(psycopg2.errors.InsufficientPrivilege):
+ cursor.execute(
+ "select purge_analysis_run_registry(%s)",
+ ("approved-retention-purge",),
+ )
+ with pytest.raises(
+ psycopg2.errors.RaiseException,
+ match="analysis_run_request_is_immutable",
+ ):
+ cursor.execute(
+ "delete from analysis_run where analysis_run_id = %s",
+ (run_id,),
+ )
+ cursor.execute("reset role")
+ try:
+ cursor.execute(
+ sql.SQL("set session authorization {}").format(
+ sql.Identifier(operator_role)
+ )
+ )
+ except psycopg2.errors.InsufficientPrivilege:
+ cursor.execute("reset session authorization")
+ pytest.skip("session authorization requires superuser")
+ cursor.execute(
+ "select purge_analysis_run_registry(%s)",
+ ("approved-retention-purge",),
+ )
+ cursor.execute("reset session authorization")
+ cursor.execute("select count(*) from analysis_run")
+ assert cursor.fetchone()[0] == 0
+ cursor.execute(
+ "select invoking_session_role, invoking_current_role "
+ "from analysis_run_retention_event"
+ )
+ invoking_session_role, invoking_current_role = cursor.fetchone()
+ assert invoking_session_role
+ assert invoking_current_role
+ finally:
+ with registry_db.cursor() as cursor:
+ cursor.execute("reset role")
+ cursor.execute("reset session authorization")
+ for role_name in (runtime_role, operator_role):
+ _drop_role_if_exists(cursor, role_name)
+
+
+def test_retention_purge_requires_admin_membership_even_with_a_grant(
+ registry_db,
+) -> None:
+ """A grant without analysis_run_retention_admin cannot empty the registry."""
+
+ role_name = f"retention_grant_only_{uuid.uuid4().hex[:8]}"
+ with registry_db.cursor() as cursor:
+ _insert_run_bearing_registry(
+ cursor,
+ digest="c" * 64,
+ idempotency_key="retention-admin-deny",
+ )
+ cursor.execute(
+ sql.SQL("create role {} nologin nosuperuser inherit").format(
+ sql.Identifier(role_name)
+ )
+ )
+ cursor.execute(
+ sql.SQL(
+ "grant execute on function purge_analysis_run_registry(text) "
+ "to {}"
+ ).format(sql.Identifier(role_name))
+ )
+ cursor.execute(
+ "insert into analysis_run_retention_grant (database_role_name) "
+ "values (%s)",
+ (role_name,),
+ )
+ try:
+ cursor.execute(
+ sql.SQL("set session authorization {}").format(
+ sql.Identifier(role_name)
+ )
+ )
+ except psycopg2.errors.InsufficientPrivilege:
+ cursor.execute("reset session authorization")
+ _drop_role_if_exists(cursor, role_name)
+ pytest.skip("session authorization requires superuser")
+ with pytest.raises(
+ psycopg2.errors.RaiseException,
+ match="analysis_run_retention_not_admin",
+ ):
+ cursor.execute(
+ "select purge_analysis_run_registry(%s)",
+ ("approved-retention-purge",),
+ )
+ cursor.execute("reset session authorization")
+ cursor.execute("select count(*) from analysis_run")
+ assert cursor.fetchone()[0] == 1
+ _drop_role_if_exists(cursor, role_name)
diff --git a/tests/test_corporate_hierarchy_inference.py b/tests/test_corporate_hierarchy_inference.py
new file mode 100644
index 000000000..b8795bb0a
--- /dev/null
+++ b/tests/test_corporate_hierarchy_inference.py
@@ -0,0 +1,71 @@
+"""Tests for lineageweave.corporate_hierarchy_inference (ADR 0010).
+
+Pure parse-function tests, same style as test_organization_name_resolution.py
+-- the HTTP mechanics are already covered by test_http_client.py.
+"""
+
+from __future__ import annotations
+
+from lineageweave.corporate_hierarchy_inference import (
+ LEVEL_COMPANY,
+ LEVEL_GROUP,
+ LEVEL_PLANT,
+ HierarchyProposal,
+ parse_inference_response,
+)
+
+
+def test_parses_a_plant_with_a_parent() -> None:
+ content = '{"level": "plant", "parent_name": "Acme Electronics"}'
+ assert parse_inference_response(content) == HierarchyProposal(
+ level_code=LEVEL_PLANT, parent_name="Acme Electronics"
+ )
+
+
+def test_parses_a_group_with_no_parent() -> None:
+ content = '{"level": "group", "parent_name": null}'
+ assert parse_inference_response(content) == HierarchyProposal(level_code=LEVEL_GROUP, parent_name=None)
+
+
+def test_company_level_recognized() -> None:
+ content = '{"level": "company", "parent_name": "Some Group"}'
+ result = parse_inference_response(content)
+ assert result is not None
+ assert result.level_code == LEVEL_COMPANY
+
+
+def test_unknown_response_returns_none() -> None:
+ assert parse_inference_response("UNKNOWN") is None
+ assert parse_inference_response("unknown\n") is None
+
+
+def test_empty_response_returns_none() -> None:
+ assert parse_inference_response("") is None
+
+
+def test_malformed_json_returns_none() -> None:
+ assert parse_inference_response("not json at all") is None
+
+
+def test_invalid_level_code_returns_none() -> None:
+ """A level outside the three valid codes must not be silently
+ accepted as if it were a real classification."""
+ content = '{"level": "division", "parent_name": null}'
+ assert parse_inference_response(content) is None
+
+
+def test_blank_parent_name_becomes_none() -> None:
+ content = '{"level": "company", "parent_name": " "}'
+ result = parse_inference_response(content)
+ assert result is not None
+ assert result.parent_name is None
+
+
+def test_markdown_fenced_json_is_rejected_not_stripped() -> None:
+ """Unlike post_summary's parser, this one does not strip code
+ fences -- the prompt asks for raw JSON only; a fenced response
+ means the model did not follow instructions and should not be
+ silently repaired into a trusted hierarchy claim.
+ """
+ content = '```json\n{"level": "company", "parent_name": null}\n```'
+ assert parse_inference_response(content) is None
diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py
new file mode 100644
index 000000000..e3ad5e67c
--- /dev/null
+++ b/tests/test_documentation_hygiene.py
@@ -0,0 +1,47 @@
+"""Permanent hygiene checks for committed architecture-decision records."""
+
+from __future__ import annotations
+
+import re
+from collections import Counter
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parents[1]
+_ADR_DIRECTORY = _ROOT / "docs" / "adr"
+_ADR_NAME = re.compile(r"^(?P[0-9]{4})-.+\.md$")
+_FORBIDDEN_MARKERS = (
+ "PLACEHOLDER_DO_NOT_WRITE",
+ "TODO_WRITE_ADR",
+)
+
+
+def test_adr_numbers_are_unique_and_documents_are_not_placeholders() -> None:
+ """Every committed ADR number identifies one substantive UTF-8 document."""
+ paths = sorted(_ADR_DIRECTORY.glob("*.md"))
+ assert paths, "the repository must contain architecture-decision records"
+
+ numbered_paths: list[tuple[str, Path]] = []
+ for path in paths:
+ match = _ADR_NAME.fullmatch(path.name)
+ assert match is not None, f"ADR filename is not numbered: {path.name}"
+ numbered_paths.append((match.group("number"), path))
+
+ content = path.read_text(encoding="utf-8")
+ assert content.strip(), f"ADR is empty: {path.relative_to(_ROOT)}"
+ for marker in _FORBIDDEN_MARKERS:
+ assert marker not in content, (
+ f"ADR contains forbidden placeholder {marker!r}: "
+ f"{path.relative_to(_ROOT)}"
+ )
+
+ counts = Counter(number for number, _ in numbered_paths)
+ duplicates = sorted(number for number, count in counts.items() if count > 1)
+ assert duplicates == [], f"duplicate ADR numbers: {duplicates}"
+
+
+def test_agents_md_locks_analysis_run_list_accname() -> None:
+ """AGENTS.md is the policy home for the list accessible-name formula."""
+ agents = " ".join((_ROOT / "AGENTS.md").read_text(encoding="utf-8").split())
+ assert "Open analysis run: {caption}. {nextAction}" in agents
+ assert "does not claim a calibrated measurement" in agents
+ assert "does not say reconstruction" in agents
diff --git a/tests/test_image_content.py b/tests/test_image_content.py
index 572909d6f..033202be6 100644
--- a/tests/test_image_content.py
+++ b/tests/test_image_content.py
@@ -82,6 +82,56 @@ def test_parse_description_preserves_multiline_ocr_text() -> None:
assert description.caption == "A scanned page."
+def test_parse_description_tolerates_markdown_emphasis_on_labels() -> None:
+ """Synthetic provider drift may bold labels without changing content."""
+ content = "**TEXT:** LT7\n**CAPTION:** A close-up of a component.\n**TAGS:** component, close-up"
+ description = _parse_description(content)
+ assert description.extracted_text == "LT7"
+ assert description.caption == "A close-up of a component."
+ assert description.tags == ("component", "close-up")
+
+
+def test_parse_description_strips_balanced_markdown_emphasis_from_values() -> None:
+ content = "TEXT: **LT7**\nCAPTION: _A synthetic component._\nTAGS: `component`, close-up"
+ description = _parse_description(content)
+ assert description.extracted_text == "LT7"
+ assert description.caption == "A synthetic component."
+ assert description.tags == ("component", "close-up")
+
+
+def test_parse_description_tolerates_reordered_labels() -> None:
+ content = "CAPTION: A blue sky.\nTEXT: NONE\nTAGS: sky"
+ description = _parse_description(content)
+ assert description.caption == "A blue sky."
+ assert description.extracted_text == ""
+ assert description.tags == ("sky",)
+
+
+def test_parse_description_missing_tags_still_recovers_text_and_caption() -> None:
+ """Missing optional tags must not discard provided TEXT/CAPTION fields."""
+ content = "TEXT: Quarterly Budget Report\nCAPTION: A printed report cover page."
+ description = _parse_description(content)
+ assert description.extracted_text == "Quarterly Budget Report"
+ assert description.caption == "A printed report cover page."
+ assert description.tags == ()
+
+
+def test_parse_description_leading_commentary_before_labels_is_ignored() -> None:
+ content = "Sure, here is the analysis:\n\nTEXT: LT7\nCAPTION: A component.\nTAGS: component"
+ description = _parse_description(content)
+ assert description.extracted_text == "LT7"
+
+
+def test_parse_description_does_not_absorb_trailing_commentary() -> None:
+ content = (
+ "TEXT: NONE\nCAPTION: A turbine diagram.\nTAGS: turbine, diagram\n"
+ "Let me know if you need more detail."
+ )
+ description = _parse_description(content)
+ assert description.caption == "A turbine diagram."
+ assert description.tags == ("turbine", "diagram")
+
+
def test_vision_client_rejects_non_http_url_schemes() -> None:
with pytest.raises(ValueError, match="unsupported vision client URL scheme: file"):
OpenAiCompatibleVisionClient(
@@ -146,3 +196,11 @@ def test_image_content_client_protocol_stub_raises() -> None:
"""
with pytest.raises(NotImplementedError):
ImageContentClient.describe(None, b"", "image/png") # type: ignore[arg-type]
+
+
+def test_parse_description_does_not_absorb_unknown_labels_into_tags() -> None:
+ parsed = _parse_description(
+ "TEXT: NONE\nCAPTION: A turbine diagram\n"
+ "TAGS: turbine, diagram\nNOTE: synthetic"
+ )
+ assert parsed.tags == ("turbine", "diagram")
diff --git a/tests/test_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py
new file mode 100644
index 000000000..d2994e2c4
--- /dev/null
+++ b/tests/test_ingestion_transaction_contracts.py
@@ -0,0 +1,509 @@
+"""Regression contracts for ingestion transactions and review documentation."""
+
+from __future__ import annotations
+
+import asyncio
+import uuid
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+from backend.app import corporate_entity_ingestion as corporate_ingestion
+from backend.app import keyman_ingestion
+from backend.app import post_summary_ingestion as summary_ingestion
+from lineageweave.corporate_hierarchy_inference import HierarchyProposal
+from lineageweave.keyman_extraction import OUR_SIDE, PersonMention
+from lineageweave.post_summary import (
+ ACTOR_TYPE_ORGANIZATION,
+ ACTOR_TYPE_TEAM,
+ PostSummary,
+ RoleResponsibility,
+)
+from lineageweave.relation_verification import STATUS_CORROBORATED
+
+
+class _RecordedTransaction:
+ """Record transaction entry and exit for one fake asyncpg connection."""
+
+ def __init__(self, events: list[Any], owner: Any | None = None) -> None:
+ self._events = events
+ self._owner = owner
+
+ async def __aenter__(self) -> "_RecordedTransaction":
+ if self._owner is not None:
+ assert not self._owner.in_transaction
+ self._owner.in_transaction = True
+ self._events.append("transaction:enter")
+ return self
+
+ async def __aexit__(self, exc_type, exc, traceback) -> bool:
+ self._events.append("transaction:exit")
+ if self._owner is not None:
+ self._owner.in_transaction = False
+ return False
+
+
+class _InferenceClient:
+ """Return one verified root-company proposal without network access."""
+
+ available = True
+
+ def __init__(self, events: list[Any]) -> None:
+ self._events = events
+
+ def infer(self, organization_name: str, context_text: str) -> HierarchyProposal:
+ self._events.append("inference")
+ return HierarchyProposal(level_code="company", parent_name=None)
+
+
+class _VerificationClient:
+ """Corroborate the synthetic proposal while recording call order."""
+
+ available = True
+
+ def __init__(self, events: list[Any]) -> None:
+ self._events = events
+
+ def verify(self, subject: str, relation: str) -> SimpleNamespace:
+ self._events.append("verification")
+ return SimpleNamespace(status_code=STATUS_CORROBORATED)
+
+
+class _CorporateConnection:
+ """Minimal asyncpg-compatible connection for creation-lock behavior."""
+
+ def __init__(
+ self,
+ events: list[Any],
+ *,
+ reloaded_rows: tuple[dict[str, Any], ...] = (),
+ inserted_id: uuid.UUID | None = None,
+ allow_insert: bool = True,
+ ) -> None:
+ self._events = events
+ self._reloaded_rows = reloaded_rows
+ self._inserted_id = inserted_id or uuid.uuid4()
+ self._allow_insert = allow_insert
+
+ def transaction(self) -> _RecordedTransaction:
+ self._events.append("transaction:open")
+ return _RecordedTransaction(self._events)
+
+ async def execute(self, query: str, *args: Any) -> str:
+ compact = " ".join(query.split())
+ assert "pg_advisory_xact_lock" in compact
+ self._events.append(("creation_lock", args, compact))
+ return "SELECT 1"
+
+ async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]:
+ compact = " ".join(query.split())
+ assert compact == "select corporate_entity_id, entity_name from corporate_entity"
+ self._events.append("candidate_reload")
+ return list(self._reloaded_rows)
+
+ async def fetchrow(self, query: str, *args: Any) -> dict[str, Any]:
+ assert self._allow_insert, "locked candidate recheck should avoid insertion"
+ compact = " ".join(query.split())
+ assert compact.startswith("insert into corporate_entity")
+ self._events.append("entity_insert")
+ return {"corporate_entity_id": self._inserted_id}
+
+
+def test_corporate_entity_creation_locks_rechecks_and_inserts_in_one_transaction() -> None:
+ """Network verification precedes one transaction-scoped global creation lock."""
+ events: list[Any] = []
+ inserted_id = uuid.uuid4()
+ connection = _CorporateConnection(events, inserted_id=inserted_id)
+ candidates: list[Any] = []
+
+ result = asyncio.run(
+ corporate_ingestion.get_or_create_corporate_entity(
+ connection,
+ "Synthetic Energy",
+ "Synthetic context",
+ _InferenceClient(events),
+ _VerificationClient(events),
+ candidates,
+ )
+ )
+
+ assert result == str(inserted_id)
+ assert [candidate.entity_name for candidate in candidates] == ["Synthetic Energy"]
+ assert events[:4] == [
+ "inference",
+ "verification",
+ "transaction:open",
+ "transaction:enter",
+ ]
+ lock_event = events[4]
+ assert lock_event[0] == "creation_lock"
+ assert lock_event[1] == ("lineageweave:corporate_entity_creation",)
+ assert events[5:] == ["candidate_reload", "entity_insert", "transaction:exit"]
+
+
+def test_locked_candidate_recheck_reuses_concurrently_created_entity() -> None:
+ """A same-name row committed after inference wins over a duplicate insert."""
+ events: list[Any] = []
+ existing_id = uuid.uuid4()
+ connection = _CorporateConnection(
+ events,
+ reloaded_rows=(
+ {
+ "corporate_entity_id": existing_id,
+ "entity_name": "Synthetic Energy",
+ },
+ ),
+ allow_insert=False,
+ )
+ candidates: list[Any] = []
+
+ result = asyncio.run(
+ corporate_ingestion.get_or_create_corporate_entity(
+ connection,
+ "Synthetic Energy",
+ "Synthetic context",
+ _InferenceClient(events),
+ _VerificationClient(events),
+ candidates,
+ )
+ )
+
+ assert result == str(existing_id)
+ assert [candidate.entity_name for candidate in candidates] == ["Synthetic Energy"]
+ assert "entity_insert" not in events
+ assert events[-1] == "transaction:exit"
+
+
+class _SummaryConnection:
+ """Minimal connection that records every post-summary database operation."""
+
+ def __init__(self, events: list[Any]) -> None:
+ self._events = events
+ self.in_transaction = False
+
+ def transaction(self) -> _RecordedTransaction:
+ self._events.append("transaction:open")
+ return _RecordedTransaction(self._events, self)
+
+ async def execute(self, query: str, *args: Any) -> str:
+ assert self.in_transaction
+ compact = " ".join(query.split())
+ self._events.append(("execute", compact))
+ return "OK"
+
+ async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
+ compact = " ".join(query.split())
+ self._events.append(("fetchrow", compact))
+ if compact.startswith("select korean_summary from post_summary_result"):
+ assert not self.in_transaction
+ return {"korean_summary": "합성 요약"}
+ if compact.startswith("select person_id from cataloged_person"):
+ assert self.in_transaction
+ return None
+ raise AssertionError(f"unexpected fetchrow query: {compact}")
+
+ async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]:
+ compact = " ".join(query.split())
+ self._events.append(("fetch", compact))
+ assert not self.in_transaction
+ if "from post_summary_event" in compact:
+ return [{"event_text": "검토 완료"}]
+ if "from post_summary_role" in compact:
+ assert "entity_name" not in compact
+ assert "cataloged_corporate_entity_id" in compact
+ return [
+ {
+ "actor_name": "Synthetic Design Team",
+ "responsibility": "도면 검토",
+ "actor_type_code": ACTOR_TYPE_TEAM,
+ "affiliated_organization_name": "Synthetic Energy",
+ "cataloged_team_id": None,
+ "cataloged_corporate_entity_id": None,
+ }
+ ]
+ raise AssertionError(f"unexpected fetch query: {compact}")
+
+
+def test_post_summary_replacement_mentions_and_edges_share_one_transaction(monkeypatch) -> None:
+ """Deletion, replacement, mention regeneration, and edges commit atomically."""
+ events: list[Any] = []
+ connection = _SummaryConnection(events)
+ team_id = str(uuid.uuid4())
+
+ async def load_candidates(conn) -> list[Any]:
+ events.append("candidate_load")
+ return []
+
+ async def upsert_team(conn, team_name, organization_name, candidates) -> str:
+ assert conn.in_transaction
+ events.append("team_upsert")
+ return team_id
+
+ async def persist_edges(conn, post_id) -> list[Any]:
+ assert conn.in_transaction
+ events.append("edge_persist")
+ return []
+
+ monkeypatch.setattr(summary_ingestion, "_load_corporate_entity_candidates", load_candidates)
+ monkeypatch.setattr(summary_ingestion, "upsert_team", upsert_team)
+ monkeypatch.setattr(summary_ingestion, "persist_edges_for_post", persist_edges)
+
+ summary = PostSummary(
+ korean_summary="합성 요약",
+ key_events=("검토 완료",),
+ roles_and_responsibilities=(
+ RoleResponsibility(
+ actor_name="Synthetic Design Team",
+ responsibility="도면 검토",
+ actor_type_code=ACTOR_TYPE_TEAM,
+ affiliated_organization_name="Synthetic Energy",
+ ),
+ ),
+ )
+
+ payload = asyncio.run(
+ summary_ingestion.persist_post_summary(
+ connection,
+ str(uuid.uuid4()),
+ summary,
+ )
+ )
+
+ enter_index = events.index("transaction:enter")
+ exit_index = events.index("transaction:exit")
+ required_sql = (
+ "delete from post_summary_person_mention",
+ "delete from post_team_mention",
+ "delete from post_organization_mention",
+ "delete from post_summary_result",
+ "insert into post_summary_result",
+ "insert into post_summary_event",
+ "insert into post_summary_role",
+ "insert into post_team_mention",
+ )
+ for fragment in required_sql:
+ operation_index = next(
+ index
+ for index, event in enumerate(events)
+ if isinstance(event, tuple)
+ and event[0] == "execute"
+ and fragment in event[1]
+ )
+ assert enter_index < operation_index < exit_index
+ assert events.index("candidate_load") < enter_index
+ assert enter_index < events.index("team_upsert") < exit_index
+ assert enter_index < events.index("edge_persist") < exit_index
+ assert payload["korean_summary"] == "합성 요약"
+
+
+def test_organization_enrichment_finishes_before_summary_transaction(monkeypatch) -> None:
+ """LLM verification and the advisory-lock transaction precede summary writes."""
+ events: list[Any] = []
+ connection = _SummaryConnection(events)
+ corporate_entity_id = str(uuid.uuid4())
+
+ async def load_candidates(conn) -> list[Any]:
+ events.append(("candidate_load", conn.in_transaction))
+ return []
+
+ async def resolve_organization(
+ conn,
+ organization_name,
+ context_text,
+ inference_client,
+ verification_client,
+ candidates,
+ ) -> str:
+ events.append(("organization_resolve", conn.in_transaction))
+ assert not conn.in_transaction
+ return corporate_entity_id
+
+ async def persist_edges(conn, post_id) -> list[Any]:
+ assert conn.in_transaction
+ events.append("edge_persist")
+ return []
+
+ monkeypatch.setattr(summary_ingestion, "_load_corporate_entity_candidates", load_candidates)
+ monkeypatch.setattr(summary_ingestion, "get_or_create_corporate_entity", resolve_organization)
+ monkeypatch.setattr(summary_ingestion, "persist_edges_for_post", persist_edges)
+
+ summary = PostSummary(
+ korean_summary="합성 요약",
+ roles_and_responsibilities=(
+ RoleResponsibility(
+ actor_name="Synthetic Energy",
+ responsibility="납품 일정 확정",
+ actor_type_code=ACTOR_TYPE_ORGANIZATION,
+ ),
+ ),
+ )
+
+ asyncio.run(
+ summary_ingestion.persist_post_summary(
+ connection,
+ str(uuid.uuid4()),
+ summary,
+ )
+ )
+
+ assert ("candidate_load", False) in events
+ assert ("organization_resolve", False) in events
+ resolve_index = events.index(("organization_resolve", False))
+ enter_index = events.index("transaction:enter")
+ exit_index = events.index("transaction:exit")
+ mention_index = next(
+ index
+ for index, event in enumerate(events)
+ if isinstance(event, tuple)
+ and event[0] == "execute"
+ and "insert into post_organization_mention" in event[1]
+ )
+ role_insert = next(
+ event[1]
+ for event in events
+ if isinstance(event, tuple)
+ and event[0] == "execute"
+ and "insert into post_summary_role" in event[1]
+ )
+ assert "cataloged_corporate_entity_id" in role_insert
+ assert resolve_index < enter_index < mention_index < exit_index
+
+
+class _KeymanConnection:
+ """Record whether organization enrichment runs outside the write transaction."""
+
+ def __init__(self, events: list[Any]) -> None:
+ self._events = events
+ self.in_transaction = False
+
+ def transaction(self) -> _RecordedTransaction:
+ self._events.append("transaction:open")
+ return _RecordedTransaction(self._events, self)
+
+ async def execute(self, query: str, *args: Any) -> str:
+ assert self.in_transaction
+ compact = " ".join(query.split())
+ self._events.append(("execute", compact))
+ return "OK"
+
+ async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]:
+ compact = " ".join(query.split())
+ self._events.append(("fetch", compact))
+ if compact == "select corporate_entity_id, entity_name from corporate_entity":
+ assert not self.in_transaction
+ return []
+ if compact.startswith("select person_id, last_known_job_title"):
+ assert self.in_transaction
+ return []
+ raise AssertionError(f"unexpected fetch query: {compact}")
+
+ async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
+ assert self.in_transaction
+ compact = " ".join(query.split())
+ self._events.append(("fetchrow", compact))
+ if compact.startswith("select person_id, last_known_job_title"):
+ return None
+ if compact.startswith("insert into cataloged_person"):
+ return {"person_id": uuid.uuid4()}
+ raise AssertionError(f"unexpected fetchrow query: {compact}")
+
+
+def test_keyman_organization_enrichment_finishes_before_write_transaction(monkeypatch) -> None:
+ """LLM resolution and hierarchy creation must not hold the Keyman write lock."""
+ events: list[Any] = []
+ connection = _KeymanConnection(events)
+ corporate_entity_id = str(uuid.uuid4())
+
+ async def resolve_name(conn, resolution_client, verification_client, organization_name, post_body) -> str:
+ events.append(("organization_resolve", conn.in_transaction))
+ assert not conn.in_transaction
+ return "Aurora Grid Power"
+
+ async def resolve_organization(
+ conn,
+ organization_name,
+ context_text,
+ inference_client,
+ verification_client,
+ candidates,
+ ) -> str:
+ events.append(("organization_create", conn.in_transaction))
+ assert not conn.in_transaction
+ return corporate_entity_id
+
+ class _Client:
+ available = True
+
+ def extract(self, post_title: str, post_body: str) -> list[PersonMention]:
+ return [
+ PersonMention(
+ "Ada West",
+ OUR_SIDE,
+ affiliated_organization_names=("AGP",),
+ )
+ ]
+
+ monkeypatch.setattr(keyman_ingestion, "resolve_organization_name", resolve_name)
+ monkeypatch.setattr(keyman_ingestion, "get_or_create_corporate_entity", resolve_organization)
+
+ asyncio.run(
+ keyman_ingestion.ingest_post_keymen(
+ connection,
+ _Client(),
+ str(uuid.uuid4()),
+ "Synthetic post",
+ "Ada West at AGP followed up.",
+ persist_graph=False,
+ )
+ )
+
+ assert ("organization_resolve", False) in events
+ assert ("organization_create", False) in events
+ resolve_index = events.index(("organization_resolve", False))
+ create_index = events.index(("organization_create", False))
+ enter_index = events.index("transaction:enter")
+ mention_index = next(
+ index
+ for index, event in enumerate(events)
+ if isinstance(event, tuple)
+ and event[0] == "execute"
+ and "insert into post_person_mention" in event[1]
+ )
+ assert resolve_index < enter_index
+ assert create_index < enter_index < mention_index
+
+
+def test_release_notes_describe_balanced_outer_emphasis_stripping() -> None:
+ """Release notes must match the parser's reviewed normalization contract."""
+ content = (Path(__file__).resolve().parents[1] / "CHANGELOG.md").read_text(
+ encoding="utf-8"
+ )
+ assert "strips balanced outer Markdown emphasis from field values" in content
+ assert "while still accepting emphasized field labels" in content
+ assert "preserves Markdown emphasis in field values" not in content
+
+
+def test_role_catalog_identity_is_stored_on_the_role_row() -> None:
+ """ADR 0019: fetch must not reconstruct organization identity by name."""
+ root = Path(__file__).resolve().parents[1]
+ fetch_source = (
+ root / "backend" / "app" / "post_summary_ingestion.py"
+ ).read_text(encoding="utf-8")
+ initial = (root / "migrations" / "0001_initial_schema.sql").read_text(
+ encoding="utf-8"
+ )
+ upgrade = (root / "migrations" / "0019_role_catalog_identity.sql").read_text(
+ encoding="utf-8"
+ )
+ dockerfile = (
+ root / "docker" / "postgres-init" / "Dockerfile"
+ ).read_text(encoding="utf-8")
+ changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8")
+ fetch_sql = fetch_source.split("async def fetch_persisted_summary", 1)[1]
+ fetch_sql = fetch_sql.split("async def persist_post_summary", 1)[0]
+ assert "org.entity_name = role.actor_name" not in fetch_sql
+ assert "cataloged_corporate_entity_id" in fetch_sql
+ assert "cataloged_team_id" in initial
+ assert "cataloged_corporate_entity_id" in upgrade
+ assert "0019_role_catalog_identity.sql" in dockerfile
+ assert "ADR 0019" in changelog
diff --git a/tests/test_keyman_extraction.py b/tests/test_keyman_extraction.py
index b1ab49da8..d9de45710 100644
--- a/tests/test_keyman_extraction.py
+++ b/tests/test_keyman_extraction.py
@@ -47,6 +47,24 @@ def test_parses_a_well_formed_json_array() -> None:
assert mentions[1].affiliated_organization_names == ("Acme Corp", "Acme Holdings")
+def test_job_title_is_captured_when_present() -> None:
+ content = '[{"name": "Kim Cheolsu", "side": "counterparty", "affiliations": [], "job_title": "Sales Manager"}]'
+ mentions = parse_keyman_response(content)
+ assert mentions[0].job_title == "Sales Manager"
+
+
+def test_job_title_is_none_not_empty_string_when_absent() -> None:
+ content = '[{"name": "Kim Cheolsu", "side": "counterparty", "affiliations": []}]'
+ mentions = parse_keyman_response(content)
+ assert mentions[0].job_title is None
+
+
+def test_null_job_title_is_none_not_the_string_null() -> None:
+ content = '[{"name": "Kim Cheolsu", "side": "counterparty", "affiliations": [], "job_title": null}]'
+ mentions = parse_keyman_response(content)
+ assert mentions[0].job_title is None
+
+
def test_strips_a_markdown_code_fence() -> None:
content = '```json\n[{"name": "Jo Park", "side": "our_side", "affiliations": []}]\n```'
mentions = parse_keyman_response(content)
@@ -110,3 +128,11 @@ def test_contextual_orchestrator_extracts_keymen_from_an_ambiguous_post() -> Non
assert jordan.person_side_code == OUR_SIDE
assert priya.person_side_code == COUNTERPARTY
assert len(priya.affiliated_organization_names) >= 2
+
+ # Sam Okonkwo is named only by role ("our legal counsel, Sam Okonkwo") --
+ # a real assertion that job_title extraction reads the text, not a
+ # synthetic fixture built just to satisfy this one field.
+ sam = next((m for name, m in by_name.items() if "Sam" in name or "Okonkwo" in name), None)
+ assert sam is not None
+ assert sam.job_title is not None
+ assert "counsel" in sam.job_title.lower() or "legal" in sam.job_title.lower()
diff --git a/tests/test_ontology.py b/tests/test_ontology.py
index 41423ada8..0e611bc8e 100644
--- a/tests/test_ontology.py
+++ b/tests/test_ontology.py
@@ -31,12 +31,31 @@
_SEED_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "seed_demo_data.py"
+# Several covered categories add lookup rows via their own migration
+# SQL rather than literally embedded in seed_demo_data.py's own source
+# text -- read alongside it below so the round-trip still sees them:
+# 0012 (ADR 0006: prov_person/prov_organization), 0014 (ADR 0007:
+# prov_team), 0016 (ADR 0009: node_team/edge_mention_team/
+# edge_team_affiliation/edge_mention_organization).
+_ADDITIONAL_LOOKUP_MIGRATION_PATHS = (
+ Path(__file__).resolve().parents[1] / "migrations" / "0012_role_responsibility_agent_type.sql",
+ Path(__file__).resolve().parents[1] / "migrations" / "0014_role_responsibility_team_actor_type.sql",
+ Path(__file__).resolve().parents[1] / "migrations" / "0016_cross_post_actor_identity.sql",
+)
+
# The categories this ontology covers (ADR 0004's scope). seed_demo_data.py
# also seeds categories this ontology deliberately does not model yet
# (post_visibility, voc_type, permission, ticket_status) -- those are
# real, expected gaps, not a test bug.
_ONTOLOGY_COVERED_CATEGORIES = frozenset(
- {"node_type", "edge_type", "entity_relationship_type", "person_side", "corporate_entity_level"}
+ {
+ "node_type",
+ "edge_type",
+ "entity_relationship_type",
+ "person_side",
+ "corporate_entity_level",
+ "prov_agent_type",
+ }
)
_INSERT_TUPLE_PATTERN = re.compile(r"\('([a-z_]+)',\s*'([a-z_]+)'")
@@ -44,12 +63,14 @@
def _seeded_lookup_codes_for_covered_categories() -> set[str]:
"""Every `(lookup_category, lookup_code)` pair seed_demo_data.py's own
- SQL literally inserts, filtered to the categories this ontology
- covers. Parsed from source, not executed -- this is a static
- consistency check between two committed files, not a live-database
- test.
+ SQL, plus the additional migrations' SQL, literally inserts, filtered
+ to the categories this ontology covers. Parsed from source, not
+ executed -- this is a static consistency check between committed
+ files, not a live-database test.
"""
- source = _SEED_SCRIPT_PATH.read_text()
+ source = _SEED_SCRIPT_PATH.read_text() + "".join(
+ p.read_text() for p in _ADDITIONAL_LOOKUP_MIGRATION_PATHS
+ )
return {
code
for category, code in _INSERT_TUPLE_PATTERN.findall(source)
@@ -129,6 +150,38 @@ def test_mentions_property_domain_and_range_match_the_schema() -> None:
assert (LW.mentions, RDFS.range, LW.Person) in graph
+def test_prov_agent_type_terms_resolve_and_subclass_real_prov_o() -> None:
+ """Beyond the generic round-trip above: the two prov_agent_type terms
+ must actually subclass the real external W3C PROV-O classes, not
+ just carry a matching :lookupCode -- the whole point of grounding
+ this in a standard ontology is that :RoleActorPerson really is a
+ prov:Person, not a same-named local invention.
+ """
+ from rdflib import URIRef
+ from rdflib.namespace import Namespace
+
+ prov = Namespace("http://www.w3.org/ns/prov#")
+ graph = load_ontology()
+ assert iri_for_lookup_code("prov_person") == str(LW.RoleActorPerson)
+ assert iri_for_lookup_code("prov_organization") == str(LW.RoleActorOrganization)
+ assert (LW.RoleActorPerson, RDFS.subClassOf, URIRef(prov.Person)) in graph
+ assert (LW.RoleActorOrganization, RDFS.subClassOf, URIRef(prov.Organization)) in graph
+
+
+def test_prov_team_type_resolves_and_subclasses_real_org_ontology() -> None:
+ """ADR 0007: a team actor is grounded in the real external W3C
+ Organization Ontology's org:OrganizationalUnit, the meso-level
+ sub-organization concept PROV-O itself has no equivalent for.
+ """
+ from rdflib import URIRef
+ from rdflib.namespace import Namespace
+
+ org = Namespace("http://www.w3.org/ns/org#")
+ graph = load_ontology()
+ assert iri_for_lookup_code("prov_team") == str(LW.RoleActorTeam)
+ assert (LW.RoleActorTeam, RDFS.subClassOf, URIRef(org.OrganizationalUnit)) in graph
+
+
def test_corporate_entity_level_hierarchy_is_broadest_first() -> None:
"""Group is broader than Company is broader than Plant -- the
Acme Group -> Acme Electronics Korea -> plant direction the
@@ -137,3 +190,12 @@ def test_corporate_entity_level_hierarchy_is_broadest_first() -> None:
assert (LW.CompanyLevel, SKOS.broader, LW.GroupLevel) in graph
assert (LW.PlantLevel, SKOS.broader, LW.CompanyLevel) in graph
assert (LW.GroupLevel, SKOS.broader, LW.CompanyLevel) not in graph
+
+
+def test_actor_mentions_follow_stored_edge_direction() -> None:
+ """Ontology domain/range matches Team/Organization -> Post storage."""
+ graph = load_ontology()
+ assert (LW.mentionsTeam, RDFS.domain, LW.Team) in graph
+ assert (LW.mentionsTeam, RDFS.range, LW.Post) in graph
+ assert (LW.mentionsOrganization, RDFS.domain, LW.CorporateEntity) in graph
+ assert (LW.mentionsOrganization, RDFS.range, LW.Post) in graph
diff --git a/tests/test_organization_name_resolution.py b/tests/test_organization_name_resolution.py
new file mode 100644
index 000000000..7eb0a96c3
--- /dev/null
+++ b/tests/test_organization_name_resolution.py
@@ -0,0 +1,125 @@
+"""Tests for lineageweave.organization_name_resolution (ADR 0008).
+
+Deterministic fake clients, same style as tests/test_post_summary.py
+and tests/test_keyman_extraction.py's pure-parse-function tests -- the
+underlying HTTP mechanics (post_json) and SearxngRelationVerificationClient's
+own HTTP behavior are already covered in test_http_client.py and
+test_relation_verification.py respectively; these tests are for this
+module's own resolve-then-verify orchestration logic.
+"""
+
+from __future__ import annotations
+
+from lineageweave.organization_name_resolution import (
+ NullOrganizationNameResolutionClient,
+ OrganizationNameResolution,
+ parse_resolution_response,
+ resolve_and_verify_organization_name,
+)
+from lineageweave.relation_verification import (
+ STATUS_CORROBORATED,
+ STATUS_PENDING,
+ STATUS_UNCORROBORATED,
+ NullRelationVerificationClient,
+ RelationVerificationResult,
+)
+
+
+class _FakeResolutionClient:
+ available = True
+
+ def __init__(self, candidate: str | None) -> None:
+ self._candidate = candidate
+ self.calls: list[tuple[str, str]] = []
+
+ def resolve(self, raw_name: str, context_text: str) -> str | None:
+ self.calls.append((raw_name, context_text))
+ return self._candidate
+
+
+class _FakeVerificationClient:
+ available = True
+
+ def __init__(self, result: RelationVerificationResult) -> None:
+ self._result = result
+ self.calls: list[tuple[str, str]] = []
+
+ def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult:
+ self.calls.append((organization_name, relationship_label))
+ return self._result
+
+
+def test_parse_resolution_response_extracts_the_first_line() -> None:
+ assert parse_resolution_response("Aurora Grid Power\n") == "Aurora Grid Power"
+
+
+def test_parse_resolution_response_rejects_unknown() -> None:
+ assert parse_resolution_response("UNKNOWN") is None
+ assert parse_resolution_response("unknown\n") is None
+
+
+def test_parse_resolution_response_rejects_empty() -> None:
+ assert parse_resolution_response("") is None
+ assert parse_resolution_response(" ") is None
+
+
+def test_no_resolution_when_client_unavailable() -> None:
+ result = resolve_and_verify_organization_name(
+ "AGP", "context", NullOrganizationNameResolutionClient(), NullRelationVerificationClient()
+ )
+ assert result is None
+
+
+def test_no_resolution_when_model_proposes_nothing() -> None:
+ result = resolve_and_verify_organization_name(
+ "AGP", "context", _FakeResolutionClient(None), NullRelationVerificationClient()
+ )
+ assert result is None
+
+
+def test_no_resolution_when_model_echoes_the_same_name() -> None:
+ """A "resolution" that just returns the raw name back is not a real
+ resolution -- must not be persisted as one."""
+ result = resolve_and_verify_organization_name(
+ "AGP", "context", _FakeResolutionClient("AGP"), NullRelationVerificationClient()
+ )
+ assert result is None
+
+
+def test_corroborated_resolution_carries_evidence() -> None:
+ verification = _FakeVerificationClient(
+ RelationVerificationResult(status_code=STATUS_CORROBORATED, evidence_url="https://example.org/agp")
+ )
+ resolution_client = _FakeResolutionClient("Aurora Grid Power")
+ result = resolve_and_verify_organization_name("AGP", "설계팀이 AGP와 회의했다", resolution_client, verification)
+ assert result == OrganizationNameResolution(
+ raw_organization_name="AGP",
+ resolved_organization_name="Aurora Grid Power",
+ verification_status_code=STATUS_CORROBORATED,
+ verification_evidence_url="https://example.org/agp",
+ )
+ # The full name and the raw abbreviation are searched together --
+ # the specific pairing is what needs corroborating, not just that
+ # the full name exists as some organization.
+ assert verification.calls == [("Aurora Grid Power", "AGP")]
+
+
+def test_uncorroborated_resolution_still_returned_with_evidence_none() -> None:
+ verification = _FakeVerificationClient(
+ RelationVerificationResult(status_code=STATUS_UNCORROBORATED, evidence_url=None)
+ )
+ result = resolve_and_verify_organization_name(
+ "AGP", "context", _FakeResolutionClient("Invented Co"), verification
+ )
+ assert result is not None
+ assert result.verification_status_code == STATUS_UNCORROBORATED
+ assert result.verification_evidence_url is None
+
+
+def test_verification_unavailable_yields_pending_not_a_fabricated_result() -> None:
+ result = resolve_and_verify_organization_name(
+ "AGP", "context", _FakeResolutionClient("Aurora Grid Power"), NullRelationVerificationClient()
+ )
+ assert result is not None
+ assert result.verification_status_code == STATUS_PENDING
+ assert result.verification_evidence_url is None
diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py
new file mode 100644
index 000000000..81e63a75f
--- /dev/null
+++ b/tests/test_person_mention_projection.py
@@ -0,0 +1,544 @@
+"""Real-PostgreSQL regressions for source-aware person and graph projections.
+
+Keyman extraction and post-summary R&R are independent evidence channels. A
+replacement in either channel must remove only that channel's stale person
+mentions, then reconcile the buyer-facing Knowledge Graph from the currently
+supported union. Orphan graph-registry rows must never become visible.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+from pathlib import Path
+from urllib.parse import urlsplit, urlunsplit
+import uuid
+
+import asyncpg
+import psycopg2
+from psycopg2 import sql
+import pytest
+
+from backend.app.keyman_ingestion import ingest_post_keymen
+from backend.app.knowledge_graph import (
+ hydrate_related_nodes,
+ load_visible_subgraph,
+ persist_edges_for_post,
+ related_for_start,
+ visible_mention_post_ids,
+)
+from backend.app import post_summary_ingestion as summary_ingestion
+from backend.app.post_summary_ingestion import (
+ fetch_persisted_summary,
+ persist_post_summary,
+)
+from lineageweave.keyman_extraction import OUR_SIDE, PersonMention
+from lineageweave.knowledge_graph import (
+ EDGE_MENTION,
+ EDGE_MENTION_TEAM,
+ NODE_CORPORATE_ENTITY,
+ NODE_PERSON,
+ NODE_POST,
+ NODE_TEAM,
+)
+from lineageweave.post_summary import (
+ ACTOR_TYPE_ORGANIZATION,
+ PostSummary,
+ RoleResponsibility,
+)
+
+_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"
+
+
+def _postgres_available() -> bool:
+ """Return whether the configured real PostgreSQL test service is reachable."""
+
+ try:
+ psycopg2.connect(_ADMIN_DSN, connect_timeout=2).close()
+ return True
+ except psycopg2.OperationalError:
+ return False
+
+
+pytestmark = pytest.mark.skipif(
+ not _postgres_available(),
+ reason=f"no reachable PostgreSQL server at {_ADMIN_DSN}",
+)
+
+
+class _KeymanClient:
+ """Mutable deterministic extractor used to model replacement runs."""
+
+ available = True
+
+ def __init__(self, mentions: list[PersonMention]) -> None:
+ self.mentions = mentions
+
+ def extract(self, post_title: str, post_body: str) -> list[PersonMention]:
+ """Return a copy so production code cannot mutate the fixture."""
+
+ return list(self.mentions)
+
+
+def _database_dsn(database_name: str) -> str:
+ """Replace only the database path while preserving DSN query parameters."""
+
+ parsed = urlsplit(_ADMIN_DSN)
+ return urlunsplit(parsed._replace(path=f"/{database_name}"))
+
+
+@pytest.fixture
+def projection_database() -> str:
+ """Create one freshly migrated PostgreSQL database and seed one post."""
+
+ database_name = f"lineageweave_projection_{uuid.uuid4().hex[:12]}"
+ admin = psycopg2.connect(_ADMIN_DSN)
+ admin.autocommit = True
+ with admin.cursor() as cursor:
+ cursor.execute(sql.SQL("create database {}").format(sql.Identifier(database_name)))
+ try:
+ database_dsn = _database_dsn(database_name)
+ connection = psycopg2.connect(database_dsn)
+ try:
+ with connection.cursor() as cursor:
+ cursor.execute(_MIGRATION_PATH.read_text(encoding="utf-8"))
+ cursor.execute(
+ """
+ insert into common_lookup_value
+ (lookup_category, lookup_code, lookup_label)
+ values
+ ('corporate_entity_level', 'company', 'Company'),
+ ('post_visibility', 'public', 'Public'),
+ ('voc_type', 'voc', 'Voice of Customer'),
+ ('person_side', 'our_side', 'Our side'),
+ ('person_side', 'counterparty', 'Counterparty'),
+ ('prov_agent_type', 'prov_person', 'Person'),
+ ('prov_agent_type', 'prov_organization', 'Organization'),
+ ('prov_agent_type', 'prov_team', 'Team'),
+ ('node_type', 'node_person', 'Person node'),
+ ('node_type', 'node_post', 'Post node'),
+ ('node_type', 'node_corporate_entity', 'Corporate node'),
+ ('node_type', 'node_team', 'Team node'),
+ ('edge_type', 'edge_mention', 'Person mentioned in'),
+ ('edge_type', 'edge_affiliation', 'Person affiliated with'),
+ ('edge_type', 'edge_co_mention', 'People co-mentioned'),
+ ('edge_type', 'edge_mention_team', 'Team mentioned in'),
+ ('edge_type', 'edge_team_affiliation', 'Team affiliated with'),
+ ('edge_type', 'edge_mention_organization', 'Organization mentioned in')
+ """
+ )
+ cursor.execute(
+ """
+ insert into corporate_entity
+ (corporate_entity_code, entity_name, entity_level_code)
+ values ('SYNTH-CORP', 'Synthetic Corp', 'company')
+ returning corporate_entity_id
+ """
+ )
+ corporate_entity_id = cursor.fetchone()[0]
+ cursor.execute(
+ """
+ insert into user_account
+ (external_subject_id, display_name, email_address)
+ values ('projection-subject', 'Projection User', 'projection@example.test')
+ returning user_account_id
+ """
+ )
+ account_id = cursor.fetchone()[0]
+ cursor.execute(
+ """
+ insert into source_post
+ (author_account_id, corporate_entity_id, post_title, post_body,
+ voc_type_code, visibility_code)
+ values (%s, %s, 'Synthetic post', 'Synthetic body', 'voc', 'public')
+ returning post_id
+ """,
+ (account_id, corporate_entity_id),
+ )
+ post_id = cursor.fetchone()[0]
+ cursor.execute(
+ """
+ insert into cataloged_person
+ (person_name, person_side_code, last_known_job_title)
+ values ('Summary Person', 'counterparty', 'Reviewer')
+ returning person_id
+ """
+ )
+ summary_person_id = cursor.fetchone()[0]
+ connection.commit()
+ finally:
+ connection.close()
+ yield "|".join((database_dsn, str(post_id), str(summary_person_id)))
+ finally:
+ with admin.cursor() as cursor:
+ cursor.execute(sql.SQL("drop database {}").format(sql.Identifier(database_name)))
+ admin.close()
+
+
+async def _exercise_projection_contract(
+ database_dsn: str,
+ post_id: str,
+ summary_person_id: str,
+) -> None:
+ """Run Keyman and R&R replacements and prove graph support follows them."""
+
+ connection = await asyncpg.connect(database_dsn)
+ try:
+ keyman = PersonMention("Keyman Person", OUR_SIDE)
+ client = _KeymanClient([keyman])
+ await ingest_post_keymen(
+ connection,
+ client,
+ post_id,
+ "Synthetic post",
+ "Synthetic body",
+ )
+ keyman_person_id = str(
+ await connection.fetchval(
+ "select person_id from cataloged_person where person_name = 'Keyman Person'"
+ )
+ )
+
+ await persist_post_summary(
+ connection,
+ post_id,
+ PostSummary(
+ korean_summary="합성 요약",
+ roles_and_responsibilities=(
+ RoleResponsibility(
+ actor_name="Summary Person",
+ responsibility="검토",
+ ),
+ ),
+ ),
+ )
+
+ keyman_rows = await connection.fetch(
+ "select person_id from post_person_mention where post_id = $1",
+ post_id,
+ )
+ summary_rows = await connection.fetch(
+ "select person_id from post_summary_person_mention where post_id = $1",
+ post_id,
+ )
+ assert {str(row["person_id"]) for row in keyman_rows} == {keyman_person_id}
+ assert {str(row["person_id"]) for row in summary_rows} == {summary_person_id}
+ assert await visible_mention_post_ids(
+ connection, summary_person_id, lambda row: True
+ ) == [post_id]
+
+ await persist_post_summary(
+ connection,
+ post_id,
+ PostSummary(korean_summary="역할이 제거된 합성 요약"),
+ )
+ assert await visible_mention_post_ids(
+ connection, summary_person_id, lambda row: True
+ ) == []
+ assert await visible_mention_post_ids(
+ connection, keyman_person_id, lambda row: True
+ ) == [post_id]
+ visible_edges = await load_visible_subgraph(connection, [post_id])
+ visible_person_ids = {
+ edge.source_node_id
+ for edge in visible_edges
+ if edge.source_node_type_code == NODE_PERSON
+ } | {
+ edge.target_node_id
+ for edge in visible_edges
+ if edge.target_node_type_code == NODE_PERSON
+ }
+ assert summary_person_id not in visible_person_ids
+ assert keyman_person_id in visible_person_ids
+
+ client.mentions = []
+ await ingest_post_keymen(
+ connection,
+ client,
+ post_id,
+ "Synthetic post",
+ "Synthetic body",
+ )
+ assert await visible_mention_post_ids(
+ connection, keyman_person_id, lambda row: True
+ ) == []
+ assert await load_visible_subgraph(connection, [post_id]) == []
+
+ async with connection.transaction():
+ await persist_edges_for_post(connection, post_id)
+ await persist_edges_for_post(connection, post_id)
+ duplicate_count = await connection.fetchval(
+ """
+ select count(*)
+ from (
+ select source_node_type_code, source_node_id,
+ target_node_type_code, target_node_id, edge_type_code
+ from knowledge_graph_edge
+ group by source_node_type_code, source_node_id,
+ target_node_type_code, target_node_id, edge_type_code
+ having count(*) > 1
+ ) duplicate_edge
+ """
+ )
+ assert duplicate_count == 0
+
+ orphan_id = await connection.fetchval(
+ """
+ insert into knowledge_graph_edge
+ (source_node_type_code, source_node_id, target_node_type_code,
+ target_node_id, edge_type_code, edge_weight)
+ values ($1, $2::uuid, $3, $4::uuid, $5, 1.0)
+ on conflict (
+ source_node_type_code, source_node_id,
+ target_node_type_code, target_node_id, edge_type_code
+ ) do update set edge_weight = excluded.edge_weight
+ returning knowledge_graph_edge_id
+ """,
+ NODE_PERSON,
+ keyman_person_id,
+ NODE_POST,
+ post_id,
+ EDGE_MENTION,
+ )
+ await connection.execute(
+ "delete from knowledge_graph_edge_evidence where knowledge_graph_edge_id = $1",
+ orphan_id,
+ )
+ assert await load_visible_subgraph(connection, [post_id]) == []
+ finally:
+ await connection.close()
+
+
+def test_person_mention_sources_reconcile_without_stale_graph_edges(
+ projection_database: str,
+) -> None:
+ """Each evidence channel replaces itself and the visible graph follows suit."""
+
+ database_dsn, post_id, summary_person_id = projection_database.split("|")
+ asyncio.run(
+ _exercise_projection_contract(database_dsn, post_id, summary_person_id)
+ )
+
+
+def test_cross_post_identity_upgrade_keeps_keyman_mention_context(
+ projection_database: str,
+) -> None:
+ """Migration 0016 copies R&R names and must not steal Keyman mention_context."""
+
+ database_dsn, post_id, summary_person_id = projection_database.split("|")
+ migration = Path(__file__).resolve().parents[1] / "migrations" / "0016_cross_post_actor_identity.sql"
+ connection = psycopg2.connect(database_dsn)
+ try:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ """
+ insert into post_person_mention (post_id, person_id, mention_context)
+ values (%s, %s, %s)
+ """,
+ (
+ post_id,
+ summary_person_id,
+ "Keyman extracted this mention from the synthetic body",
+ ),
+ )
+ cursor.execute(
+ "insert into post_summary_result (post_id, korean_summary) values (%s, %s)",
+ (post_id, "합성 요약"),
+ )
+ cursor.execute(
+ """
+ insert into post_summary_role
+ (post_id, actor_name, responsibility, actor_type_code)
+ values (%s, 'Summary Person', '검토', 'prov_person')
+ """,
+ (post_id,),
+ )
+ cursor.execute(migration.read_text(encoding="utf-8"))
+ cursor.execute(
+ """
+ select mention_context
+ from post_person_mention
+ where post_id = %s and person_id = %s
+ """,
+ (post_id, summary_person_id),
+ )
+ keyman_row = cursor.fetchone()
+ cursor.execute(
+ """
+ select count(*)
+ from post_summary_person_mention
+ where post_id = %s and person_id = %s
+ """,
+ (post_id, summary_person_id),
+ )
+ summary_count = cursor.fetchone()[0]
+ connection.commit()
+ finally:
+ connection.close()
+
+ assert keyman_row is not None
+ assert keyman_row[0] == "Keyman extracted this mention from the synthetic body"
+ assert summary_count == 1
+
+
+async def _exercise_team_only_related_walk(
+ database_dsn: str,
+ first_post_id: str,
+) -> None:
+ """A team mentioned on two posts must walk even when one post has no people."""
+
+ connection = await asyncpg.connect(database_dsn)
+ try:
+ author_id, corporate_entity_id = await connection.fetchrow(
+ "select author_account_id, corporate_entity_id from source_post where post_id = $1",
+ first_post_id,
+ )
+ second_post_id = str(
+ await connection.fetchval(
+ """
+ insert into source_post
+ (author_account_id, corporate_entity_id, post_title, post_body,
+ voc_type_code, visibility_code)
+ values ($1, $2, 'Team-only follow-up', '설계팀이 도면을 재검토했다.',
+ 'voc', 'public')
+ returning post_id
+ """,
+ author_id,
+ corporate_entity_id,
+ )
+ )
+ team_id = str(
+ await connection.fetchval(
+ """
+ insert into cataloged_team (team_name, affiliated_organization_name)
+ values ('설계팀', 'Synthetic Corp')
+ returning team_id
+ """
+ )
+ )
+ await connection.execute(
+ """
+ insert into post_team_mention (post_id, team_id)
+ values ($1, $2), ($3, $2)
+ """,
+ first_post_id,
+ team_id,
+ second_post_id,
+ )
+ async with connection.transaction():
+ await persist_edges_for_post(connection, first_post_id)
+ await persist_edges_for_post(connection, second_post_id)
+
+ team_only_edges = await load_visible_subgraph(connection, [second_post_id])
+ assert any(
+ edge.edge_type_code == EDGE_MENTION_TEAM
+ and edge.source_node_id == team_id
+ and edge.target_node_id == second_post_id
+ for edge in team_only_edges
+ ), "a team-only post must still load its mention edge"
+
+ related = await related_for_start(
+ connection, NODE_TEAM, team_id, [first_post_id, second_post_id]
+ )
+ related_ids = {node["node_id"] for node in related}
+ assert first_post_id in related_ids
+ assert second_post_id in related_ids
+ hydrated = await hydrate_related_nodes(
+ connection, [(f"{NODE_TEAM}:{team_id}", 1.0)]
+ )
+ assert hydrated[0]["label"] == "설계팀"
+ assert hydrated[0]["node_type_code"] == NODE_TEAM
+ finally:
+ await connection.close()
+
+
+def test_team_only_posts_walk_related_nodes(projection_database: str) -> None:
+ """ADR 0018: team mention edges must participate in the visible RWR walk."""
+
+ database_dsn, post_id, _summary_person_id = projection_database.split("|")
+ asyncio.run(_exercise_team_only_related_walk(database_dsn, post_id))
+
+
+async def _exercise_homonym_organization_role_binding(
+ database_dsn: str,
+ post_id: str,
+) -> None:
+ """A same-named catalog org that this post did not resolve must stay off the role."""
+
+ connection = await asyncpg.connect(database_dsn)
+ try:
+ mentioned_id = str(
+ await connection.fetchval(
+ """
+ insert into corporate_entity
+ (corporate_entity_code, entity_name, entity_level_code)
+ values ('HOMONYM-MENTIONED', 'Homonym Energy', 'company')
+ returning corporate_entity_id
+ """
+ )
+ )
+ other_id = str(
+ await connection.fetchval(
+ """
+ insert into corporate_entity
+ (corporate_entity_code, entity_name, entity_level_code)
+ values ('HOMONYM-OTHER', 'Homonym Energy', 'company')
+ returning corporate_entity_id
+ """
+ )
+ )
+
+ async def resolve_mentioned_organization(*_args, **_kwargs) -> str:
+ return mentioned_id
+
+ original = summary_ingestion.get_or_create_corporate_entity
+ summary_ingestion.get_or_create_corporate_entity = resolve_mentioned_organization
+ try:
+ payload = await persist_post_summary(
+ connection,
+ post_id,
+ PostSummary(
+ korean_summary="동명이인 조직이 일정만 확정했다.",
+ roles_and_responsibilities=(
+ RoleResponsibility(
+ actor_name="Homonym Energy",
+ responsibility="납품 일정 확정",
+ actor_type_code=ACTOR_TYPE_ORGANIZATION,
+ ),
+ ),
+ ),
+ )
+ finally:
+ summary_ingestion.get_or_create_corporate_entity = original
+
+ roles = payload["roles_and_responsibilities"]
+ assert len(roles) == 1
+ assert roles[0]["catalog_node_id"] == mentioned_id
+ assert roles[0]["catalog_node_type_code"] == NODE_CORPORATE_ENTITY
+ fetched = await fetch_persisted_summary(connection, post_id)
+ assert fetched is not None
+ assert fetched["roles_and_responsibilities"][0]["catalog_node_id"] == mentioned_id
+ assert fetched["roles_and_responsibilities"][0]["catalog_node_id"] != other_id
+ mention_ids = [
+ str(row["corporate_entity_id"])
+ for row in await connection.fetch(
+ "select corporate_entity_id from post_organization_mention "
+ "where post_id = $1",
+ post_id,
+ )
+ ]
+ assert mention_ids == [mentioned_id]
+ finally:
+ await connection.close()
+
+
+def test_homonym_organization_role_binds_the_resolved_catalog_id(
+ projection_database: str,
+) -> None:
+ """ADR 0019: two catalog orgs can share a display name; the role keeps one id."""
+
+ database_dsn, post_id, _summary_person_id = projection_database.split("|")
+ asyncio.run(_exercise_homonym_organization_role_binding(database_dsn, post_id))
diff --git a/tests/test_post_summary.py b/tests/test_post_summary.py
index 52694bc3b..4f863769a 100644
--- a/tests/test_post_summary.py
+++ b/tests/test_post_summary.py
@@ -24,6 +24,7 @@
from lineageweave.post_summary import (
ContextualOrchestratorPostSummaryClient,
NullPostSummaryClient,
+ RoleResponsibility,
parse_summary_response,
)
@@ -39,13 +40,72 @@ def test_parses_a_well_formed_json_object() -> None:
content = (
'{"korean_summary": "회의 후속 조치에 대한 요약입니다.", '
'"key_events": ["입찰 워크숍 진행", "검사 일정 확인 요청"], '
- '"roles_and_responsibilities": [{"person_name": "Jordan Hale", "responsibility": "입찰 일정 안내"}]}'
+ '"roles_and_responsibilities": [{"actor_name": "Jordan Hale", "responsibility": "입찰 일정 안내", '
+ '"actor_type": "person", "affiliated_organization_name": "Westfield Power"}]}'
)
summary = parse_summary_response(content)
assert summary is not None
assert summary.korean_summary == "회의 후속 조치에 대한 요약입니다."
assert summary.key_events == ("입찰 워크숍 진행", "검사 일정 확인 요청")
- assert summary.roles_and_responsibilities[0].person_name == "Jordan Hale"
+ role = summary.roles_and_responsibilities[0]
+ assert role.actor_name == "Jordan Hale"
+ assert role.actor_type_code == "prov_person"
+ assert role.affiliated_organization_name == "Westfield Power"
+
+
+def test_organization_actor_is_not_forced_into_a_person_slot() -> None:
+ """A named actor that is genuinely an organization (e.g. our own
+ company acting in its own name, not a named individual) must parse
+ as ``prov_organization``, not silently default to person -- the
+ default only applies when the model omits ``actor_type`` entirely.
+ """
+ content = (
+ '{"korean_summary": "당사가 요청 사항을 확인했습니다.", "key_events": [], '
+ '"roles_and_responsibilities": [{"actor_name": "당사", "responsibility": "요청 확인", '
+ '"actor_type": "organization", "affiliated_organization_name": null}]}'
+ )
+ summary = parse_summary_response(content)
+ assert summary is not None
+ role = summary.roles_and_responsibilities[0]
+ assert role.actor_name == "당사"
+ assert role.actor_type_code == "prov_organization"
+ assert role.affiliated_organization_name is None
+
+
+def test_team_actor_is_meso_level_not_organization() -> None:
+ """A named sub-unit of a company (e.g. 설계팀, "design team") must
+ parse as ``prov_team``, distinct from both ``prov_person`` and
+ ``prov_organization`` -- it is part of a company, not the company
+ itself (ADR 0007), and its parent company's name must still land in
+ ``affiliated_organization_name``.
+ """
+ content = (
+ '{"korean_summary": "설계팀이 도면을 검토했습니다.", "key_events": [], '
+ '"roles_and_responsibilities": [{"actor_name": "설계팀", "responsibility": "도면 검토", '
+ '"actor_type": "team", "affiliated_organization_name": "Demo Corp"}]}'
+ )
+ summary = parse_summary_response(content)
+ assert summary is not None
+ role = summary.roles_and_responsibilities[0]
+ assert role.actor_name == "설계팀"
+ assert role.actor_type_code == "prov_team"
+ assert role.affiliated_organization_name == "Demo Corp"
+
+
+def test_unknown_actor_type_code_is_rejected() -> None:
+ with pytest.raises(ValueError, match="actor_type_code"):
+ RoleResponsibility(actor_name="Ada West", responsibility="후속", actor_type_code="person")
+
+
+def test_missing_actor_type_defaults_to_person() -> None:
+ content = (
+ '{"korean_summary": "요약", "key_events": [], '
+ '"roles_and_responsibilities": [{"actor_name": "Ada West", "responsibility": "후속"}]}'
+ )
+ summary = parse_summary_response(content)
+ assert summary is not None
+ assert summary.roles_and_responsibilities[0].actor_type_code == "prov_person"
+ assert summary.roles_and_responsibilities[0].affiliated_organization_name is None
def test_missing_korean_summary_returns_none() -> None:
@@ -75,7 +135,7 @@ def test_every_sample_record_has_a_seeded_korean_summary() -> None:
assert summary.korean_summary not in seen
seen.add(summary.korean_summary)
cast = fixture_thread_cast(rec.label)
- names = {role.person_name for role in summary.roles_and_responsibilities}
+ names = {role.actor_name for role in summary.roles_and_responsibilities}
if cast is not None and cast.person_names:
assert set(cast.person_names) <= names
else:
@@ -91,7 +151,7 @@ def test_every_sample_record_has_a_seeded_korean_summary() -> None:
def test_malformed_roles_entries_are_skipped_not_crashed_on() -> None:
content = (
'{"korean_summary": "요약", "key_events": [], '
- '"roles_and_responsibilities": [{"person_name": "Only Name"}, "not an object"]}'
+ '"roles_and_responsibilities": [{"actor_name": "Only Name"}, "not an object"]}'
)
summary = parse_summary_response(content)
assert summary is not None
@@ -119,5 +179,5 @@ def test_contextual_orchestrator_summarizes_a_non_trivial_post() -> None:
# block -- not just an English sentence handed back unchanged.
assert any("가" <= ch <= "힣" for ch in summary.korean_summary)
assert len(summary.key_events) >= 1
- people_named = {rr.person_name for rr in summary.roles_and_responsibilities}
+ people_named = {rr.actor_name for rr in summary.roles_and_responsibilities}
assert any("Jordan" in name or "Priya" in name for name in people_named)
diff --git a/tests/test_prov_o.py b/tests/test_prov_o.py
new file mode 100644
index 000000000..2a33a9b8c
--- /dev/null
+++ b/tests/test_prov_o.py
@@ -0,0 +1,401 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from pathlib import Path
+
+import sys
+
+import pytest
+from rdflib import Graph, Literal, Namespace, URIRef
+from rdflib.namespace import RDF, XSD
+
+from lineageweave.prov_o import (
+ PROV,
+ PROV_CLASSES,
+ PROV_QUALIFICATIONS,
+ PROV_RELATIONS,
+ PROV_RECOMMENDED_INVERSES,
+ ProvAssertion,
+ ProvGraph,
+ ProvLiteral,
+ ProvValidationError,
+ class_code,
+ relation_code,
+)
+
+EXPECTED_CLASS_NAMES = {
+ "Entity", "Activity", "Agent", "Collection", "EmptyCollection", "Bundle",
+ "Person", "SoftwareAgent", "Organization", "Location", "Influence",
+ "EntityInfluence", "Usage", "Start", "End", "Derivation", "PrimarySource",
+ "Quotation", "Revision", "ActivityInfluence", "Generation", "Communication",
+ "Invalidation", "AgentInfluence", "Attribution", "Association", "Plan",
+ "Delegation", "InstantaneousEvent", "Role",
+}
+
+EXPECTED_RELATION_NAMES = {
+ "wasGeneratedBy", "wasDerivedFrom", "wasAttributedTo", "startedAtTime", "used",
+ "wasInformedBy", "endedAtTime", "wasAssociatedWith", "actedOnBehalfOf",
+ "alternateOf", "specializationOf", "generatedAtTime", "hadPrimarySource", "value",
+ "wasQuotedFrom", "wasRevisionOf", "invalidatedAtTime", "wasInvalidatedBy",
+ "hadMember", "wasStartedBy", "wasEndedBy", "invalidated", "influenced",
+ "atLocation", "generated", "wasInfluencedBy", "qualifiedInfluence",
+ "qualifiedGeneration", "qualifiedDerivation", "qualifiedPrimarySource",
+ "qualifiedQuotation", "qualifiedRevision", "qualifiedAttribution",
+ "qualifiedInvalidation", "qualifiedStart", "qualifiedUsage",
+ "qualifiedCommunication", "qualifiedAssociation", "qualifiedEnd",
+ "qualifiedDelegation", "influencer", "entity", "hadUsage", "hadGeneration",
+ "activity", "agent", "hadPlan", "hadActivity", "atTime", "hadRole",
+}
+
+EXPECTED_DATATYPE_RELATIONS = {
+ "startedAtTime", "endedAtTime", "generatedAtTime", "invalidatedAtTime", "value", "atTime"
+}
+
+EXPECTED_QUALIFICATIONS = {
+ "wasGeneratedBy": ("qualifiedGeneration", "Generation", "activity"),
+ "wasDerivedFrom": ("qualifiedDerivation", "Derivation", "entity"),
+ "wasAttributedTo": ("qualifiedAttribution", "Attribution", "agent"),
+ "used": ("qualifiedUsage", "Usage", "entity"),
+ "wasInformedBy": ("qualifiedCommunication", "Communication", "activity"),
+ "wasAssociatedWith": ("qualifiedAssociation", "Association", "agent"),
+ "actedOnBehalfOf": ("qualifiedDelegation", "Delegation", "agent"),
+ "wasInfluencedBy": ("qualifiedInfluence", "Influence", "influencer"),
+ "hadPrimarySource": ("qualifiedPrimarySource", "PrimarySource", "entity"),
+ "wasQuotedFrom": ("qualifiedQuotation", "Quotation", "entity"),
+ "wasRevisionOf": ("qualifiedRevision", "Revision", "entity"),
+ "wasInvalidatedBy": ("qualifiedInvalidation", "Invalidation", "activity"),
+ "wasStartedBy": ("qualifiedStart", "Start", "entity"),
+ "wasEndedBy": ("qualifiedEnd", "End", "entity"),
+}
+
+
+def test_registry_contains_every_normative_prov_o_class_and_relation() -> None:
+ assert set(PROV_CLASSES) == EXPECTED_CLASS_NAMES
+ assert set(PROV_RELATIONS) == EXPECTED_RELATION_NAMES
+ assert len(PROV_CLASSES) == 30
+ assert len(PROV_RELATIONS) == 50
+
+
+def test_registry_distinguishes_all_six_datatype_properties() -> None:
+ actual = {name for name, spec in PROV_RELATIONS.items() if spec.property_kind == "datatype"}
+ assert actual == EXPECTED_DATATYPE_RELATIONS
+ assert {name for name, spec in PROV_RELATIONS.items() if spec.property_kind == "object"} == (
+ EXPECTED_RELATION_NAMES - EXPECTED_DATATYPE_RELATIONS
+ )
+
+
+def test_qualification_table_matches_both_normative_tables() -> None:
+ actual = {
+ item.unqualified_relation: (
+ item.qualification_relation,
+ item.influence_class,
+ item.influencer_relation,
+ )
+ for item in PROV_QUALIFICATIONS
+ }
+ assert actual == EXPECTED_QUALIFICATIONS
+
+
+def test_every_object_property_has_the_appendix_b_inverse_name() -> None:
+ object_properties = {
+ name for name, spec in PROV_RELATIONS.items() if spec.property_kind == "object"
+ }
+ assert set(PROV_RECOMMENDED_INVERSES) == object_properties
+ assert PROV_RECOMMENDED_INVERSES["actedOnBehalfOf"].inverse_local_name == "hadDelegate"
+ assert PROV_RECOMMENDED_INVERSES["wasDerivedFrom"].inverse_local_name == "hadDerivation"
+ assert PROV_RECOMMENDED_INVERSES["specializationOf"].inverse_local_name == "generalizationOf"
+ assert PROV_RECOMMENDED_INVERSES["wasGeneratedBy"].inverse_local_name == "generated"
+ assert PROV_RECOMMENDED_INVERSES["alternateOf"].inverse_local_name == "alternateOf"
+
+
+def test_codes_are_stable_two_word_snake_case() -> None:
+ assert class_code("Entity") == "prov_entity"
+ assert class_code("InstantaneousEvent") == "prov_instantaneous_event"
+ assert relation_code("wasGeneratedBy") == "prov_was_generated_by"
+ assert relation_code("qualifiedPrimarySource") == "prov_qualified_primary_source"
+ for name in PROV_CLASSES:
+ assert class_code(name).startswith("prov_") and "_" in class_code(name)
+ for name in PROV_RELATIONS:
+ assert relation_code(name).startswith("prov_") and "_" in relation_code(name)
+
+
+def _graph_with_core_resources() -> ProvGraph:
+ graph = ProvGraph()
+ graph.add_resource("urn:entity:input", "Entity")
+ graph.add_resource("urn:entity:output", "Entity")
+ graph.add_resource("urn:activity:transform", "Activity")
+ graph.add_resource("urn:agent:operator", "Person")
+ graph.add_resource("urn:agent:principal", "Organization")
+ graph.add_resource("urn:location:lab", "Location")
+ graph.add_resource("urn:plan:procedure", "Plan")
+ graph.add_resource("urn:role:reviewer", "Role")
+ return graph
+
+
+def test_graph_rejects_wrong_object_kind_and_wrong_domain() -> None:
+ graph = _graph_with_core_resources()
+ with pytest.raises(ProvValidationError, match="requires a resource object"):
+ graph.add_assertion("urn:activity:transform", "used", ProvLiteral("not-a-resource"))
+ with pytest.raises(ProvValidationError, match="requires a literal object"):
+ graph.add_assertion("urn:activity:transform", "startedAtTime", "urn:entity:input")
+ with pytest.raises(ProvValidationError, match="subject.*Entity"):
+ graph.add_assertion("urn:agent:operator", "wasDerivedFrom", "urn:entity:input")
+
+
+def test_subclass_membership_satisfies_agent_domain() -> None:
+ graph = _graph_with_core_resources()
+ graph.add_assertion("urn:agent:operator", "actedOnBehalfOf", "urn:agent:principal")
+ assert ProvAssertion.resource(
+ "urn:agent:operator", "actedOnBehalfOf", "urn:agent:principal"
+ ) in graph.explicit_assertions
+
+
+@pytest.mark.parametrize(
+ ("unqualified", "qualified", "influence_class", "influencer_relation", "subject", "object_iri"),
+ [
+ ("wasGeneratedBy", "qualifiedGeneration", "Generation", "activity", "urn:entity:output", "urn:activity:transform"),
+ ("wasDerivedFrom", "qualifiedDerivation", "Derivation", "entity", "urn:entity:output", "urn:entity:input"),
+ ("wasAttributedTo", "qualifiedAttribution", "Attribution", "agent", "urn:entity:output", "urn:agent:operator"),
+ ("used", "qualifiedUsage", "Usage", "entity", "urn:activity:transform", "urn:entity:input"),
+ ("wasInformedBy", "qualifiedCommunication", "Communication", "activity", "urn:activity:transform", "urn:activity:source"),
+ ("wasAssociatedWith", "qualifiedAssociation", "Association", "agent", "urn:activity:transform", "urn:agent:operator"),
+ ("actedOnBehalfOf", "qualifiedDelegation", "Delegation", "agent", "urn:agent:operator", "urn:agent:principal"),
+ ("wasInfluencedBy", "qualifiedInfluence", "Influence", "influencer", "urn:entity:output", "urn:entity:input"),
+ ("hadPrimarySource", "qualifiedPrimarySource", "PrimarySource", "entity", "urn:entity:output", "urn:entity:input"),
+ ("wasQuotedFrom", "qualifiedQuotation", "Quotation", "entity", "urn:entity:output", "urn:entity:input"),
+ ("wasRevisionOf", "qualifiedRevision", "Revision", "entity", "urn:entity:output", "urn:entity:input"),
+ ("wasInvalidatedBy", "qualifiedInvalidation", "Invalidation", "activity", "urn:entity:output", "urn:activity:transform"),
+ ("wasStartedBy", "qualifiedStart", "Start", "entity", "urn:activity:transform", "urn:entity:input"),
+ ("wasEndedBy", "qualifiedEnd", "End", "entity", "urn:activity:transform", "urn:entity:output"),
+ ],
+)
+def test_each_qualified_form_implies_its_unqualified_form(
+ unqualified: str,
+ qualified: str,
+ influence_class: str,
+ influencer_relation: str,
+ subject: str,
+ object_iri: str,
+) -> None:
+ graph = _graph_with_core_resources()
+ graph.add_resource("urn:activity:source", "Activity")
+ graph.add_resource("urn:influence:q", influence_class)
+ graph.add_assertion(subject, qualified, "urn:influence:q")
+ graph.add_assertion("urn:influence:q", influencer_relation, object_iri)
+ assert ProvAssertion.resource(subject, unqualified, object_iri) in graph.materialized_assertions()
+
+
+def test_specific_derivation_implies_general_derivation_and_influence() -> None:
+ graph = _graph_with_core_resources()
+ graph.add_assertion("urn:entity:output", "wasQuotedFrom", "urn:entity:input")
+ materialized = graph.materialized_assertions()
+ assert ProvAssertion.resource("urn:entity:output", "wasDerivedFrom", "urn:entity:input") in materialized
+ assert ProvAssertion.resource("urn:entity:output", "wasInfluencedBy", "urn:entity:input") in materialized
+
+
+def test_defined_inverse_and_symmetric_properties_are_materialized() -> None:
+ graph = _graph_with_core_resources()
+ graph.add_assertion("urn:entity:output", "wasGeneratedBy", "urn:activity:transform")
+ graph.add_assertion("urn:entity:output", "alternateOf", "urn:entity:input")
+ materialized = graph.materialized_assertions()
+ assert ProvAssertion.resource("urn:activity:transform", "generated", "urn:entity:output") in materialized
+ assert ProvAssertion.resource("urn:entity:input", "alternateOf", "urn:entity:output") in materialized
+
+
+def test_reserved_inverse_alias_is_normalized_by_reversing_endpoints() -> None:
+ graph = _graph_with_core_resources()
+ graph.add_assertion("urn:entity:input", "hadDerivation", "urn:entity:output")
+ assert ProvAssertion.resource(
+ "urn:entity:output", "wasDerivedFrom", "urn:entity:input"
+ ) in graph.explicit_assertions
+
+
+def test_qualified_event_time_implies_direct_time_property() -> None:
+ graph = _graph_with_core_resources()
+ graph.add_resource("urn:influence:generation", "Generation")
+ instant = ProvLiteral.datetime(datetime(2026, 8, 14, 4, 0, tzinfo=timezone.utc))
+ graph.add_assertion("urn:entity:output", "qualifiedGeneration", "urn:influence:generation")
+ graph.add_assertion("urn:influence:generation", "activity", "urn:activity:transform")
+ graph.add_assertion("urn:influence:generation", "atTime", instant)
+ materialized = graph.materialized_assertions()
+ assert ProvAssertion.literal("urn:entity:output", "generatedAtTime", instant) in materialized
+
+
+def test_rdf_serialization_uses_exact_prov_namespace_and_xsd_datetime() -> None:
+ graph = _graph_with_core_resources()
+ instant = ProvLiteral.datetime(datetime(2026, 8, 14, 4, 0, tzinfo=timezone.utc))
+ graph.add_assertion("urn:activity:transform", "startedAtTime", instant)
+ rdf_graph = graph.to_rdflib(materialize=True)
+ assert (URIRef("urn:entity:input"), RDF.type, PROV.Entity) in rdf_graph
+ assert (
+ URIRef("urn:activity:transform"),
+ PROV.startedAtTime,
+ Literal("2026-08-14T04:00:00+00:00", datatype=XSD.dateTime),
+ ) in rdf_graph
+
+
+def test_sql_migration_seeds_every_class_relation_and_qualification() -> None:
+ sql_path = Path(__file__).resolve().parents[1] / "migrations" / "0017_prov_o_standard_relations.sql"
+ sql = sql_path.read_text()
+ for name in EXPECTED_CLASS_NAMES:
+ assert class_code(name) in sql
+ assert f"http://www.w3.org/ns/prov#{name}" in sql
+ for name in EXPECTED_RELATION_NAMES:
+ assert relation_code(name) in sql
+ assert f"http://www.w3.org/ns/prov#{name}" in sql
+ for unqualified, (qualified, influence_class, influencer) in EXPECTED_QUALIFICATIONS.items():
+ assert relation_code(unqualified) in sql
+ assert relation_code(qualified) in sql
+ assert class_code(influence_class) in sql
+ assert relation_code(influencer) in sql
+
+
+def test_sql_migration_uses_only_multiword_snake_case_table_names() -> None:
+ import re
+
+ sql = (Path(__file__).resolve().parents[1] / "migrations" / "0017_prov_o_standard_relations.sql").read_text()
+ names = re.findall(r"create table(?: if not exists)?\s+([a-z_]+)", sql, flags=re.IGNORECASE)
+ assert names
+ assert all(len(name.split("_")) >= 2 for name in names)
+
+
+def test_registry_spec_accessors_and_inverse_iri_use_exact_namespace() -> None:
+ assert PROV_CLASSES["Entity"].iri == "http://www.w3.org/ns/prov#Entity"
+ assert PROV_CLASSES["Entity"].code == "prov_entity"
+ assert PROV_RELATIONS["used"].iri == "http://www.w3.org/ns/prov#used"
+ assert PROV_RELATIONS["used"].code == "prov_used"
+ assert (
+ PROV_RECOMMENDED_INVERSES["actedOnBehalfOf"].inverse_iri
+ == "http://www.w3.org/ns/prov#hadDelegate"
+ )
+
+
+def test_literal_contract_rejects_conflicts_invalid_language_and_naive_time() -> None:
+ with pytest.raises(ProvValidationError, match="both datatype_iri and language_tag"):
+ ProvLiteral("x", datatype_iri=str(XSD.string), language_tag="en")
+ with pytest.raises(ProvValidationError, match="language_tag"):
+ ProvLiteral("x", language_tag="not_a_tag!")
+ with pytest.raises(ProvValidationError, match="timezone-aware"):
+ ProvLiteral.datetime(datetime(2026, 8, 14, 4, 0))
+ assert ProvLiteral("bonjour", language_tag="fr").to_rdflib() == Literal("bonjour", lang="fr")
+
+
+def test_assertion_requires_exactly_one_object_kind() -> None:
+ with pytest.raises(ProvValidationError, match="exactly one"):
+ ProvAssertion("urn:s", "used")
+ with pytest.raises(ProvValidationError, match="exactly one"):
+ ProvAssertion(
+ "urn:s",
+ "used",
+ object_resource_iri="urn:o",
+ object_literal=ProvLiteral("x"),
+ )
+
+
+def test_resource_registration_and_name_normalization_fail_closed() -> None:
+ graph = ProvGraph()
+ with pytest.raises(ProvValidationError, match="resource_iri"):
+ graph.add_resource("", "Entity")
+ with pytest.raises(ProvValidationError, match="at least one"):
+ graph.add_resource("urn:empty")
+ with pytest.raises(ProvValidationError, match="unknown PROV-O class"):
+ graph.add_resource("urn:bad", "NotAClass")
+
+ graph.add_resource("urn:e", "prov:Entity")
+ graph.add_resource("urn:a", "http://www.w3.org/ns/prov#Activity")
+ assert graph.resource_types == {
+ "urn:e": frozenset({"Entity"}),
+ "urn:a": frozenset({"Activity"}),
+ }
+
+
+def test_assertion_name_and_endpoint_validation_fail_closed() -> None:
+ graph = _graph_with_core_resources()
+ with pytest.raises(ProvValidationError, match="unknown PROV-O relation"):
+ graph.add_assertion("urn:entity:input", "notARelation", "urn:entity:output")
+ with pytest.raises(ProvValidationError, match="subject resource"):
+ graph.add_assertion("urn:missing", "prov:wasDerivedFrom", "urn:entity:input")
+ with pytest.raises(ProvValidationError, match="object resource"):
+ graph.add_assertion(
+ "urn:entity:output",
+ "http://www.w3.org/ns/prov#wasDerivedFrom",
+ "urn:missing",
+ )
+ with pytest.raises(ProvValidationError, match="object.*Entity"):
+ graph.add_assertion("urn:activity:transform", "used", "urn:role:reviewer")
+ with pytest.raises(ProvValidationError, match="requires datatype"):
+ graph.add_assertion(
+ "urn:activity:transform",
+ "startedAtTime",
+ ProvLiteral("2026-08-14T04:00:00Z"),
+ )
+ with pytest.raises(ProvValidationError, match="cannot reverse a literal"):
+ graph.add_assertion(
+ "urn:entity:input",
+ "hadDerivation",
+ ProvLiteral("invalid"),
+ )
+
+
+def test_rdf_serialization_covers_resource_and_literal_objects_without_materialization() -> None:
+ graph = _graph_with_core_resources()
+ graph.add_assertion("urn:activity:transform", "used", "urn:entity:input")
+ graph.add_assertion("urn:entity:input", "value", ProvLiteral("raw value"))
+ rdf_graph = graph.to_rdflib()
+ assert (
+ URIRef("urn:activity:transform"),
+ PROV.used,
+ URIRef("urn:entity:input"),
+ ) in rdf_graph
+ assert (
+ URIRef("urn:entity:input"),
+ PROV.value,
+ Literal("raw value"),
+ ) in rdf_graph
+
+
+def test_every_public_callable_has_a_docstring() -> None:
+ import inspect
+
+ module = sys.modules["lineageweave.prov_o"]
+
+ missing: list[str] = []
+ for name, value in vars(module).items():
+ if name.startswith("_"):
+ continue
+ if inspect.isfunction(value) or inspect.isclass(value):
+ if value.__module__ == module.__name__ and not inspect.getdoc(value):
+ missing.append(name)
+ if inspect.isclass(value) and value.__module__ == module.__name__:
+ for member_name, member in vars(value).items():
+ if member_name.startswith("_"):
+ continue
+ target = member.fget if isinstance(member, property) else member
+ if callable(target) and not inspect.getdoc(target):
+ missing.append(f"{name}.{member_name}")
+ assert missing == []
+
+
+def test_support_profile_imports_prov_o_and_maps_product_classes() -> None:
+ from rdflib.namespace import OWL, RDFS
+
+ profile_path = (
+ Path(__file__).resolve().parents[1]
+ / "docs"
+ / "ontology"
+ / "prov-o-support-profile.ttl"
+ )
+ profile = Graph().parse(profile_path, format="turtle")
+ ontology_iri = URIRef(
+ "https://contextualwisdomlab.github.io/LineageWeave/prov-o-support"
+ )
+ local = Namespace("https://contextualwisdomlab.github.io/LineageWeave/ontology#")
+ assert (
+ ontology_iri,
+ OWL.imports,
+ URIRef("http://www.w3.org/ns/prov-o#"),
+ ) in profile
+ assert (local.Post, RDFS.subClassOf, PROV.Entity) in profile
+ assert (local.Person, RDFS.subClassOf, PROV.Person) in profile
+ assert (local.CorporateEntity, RDFS.subClassOf, PROV.Organization) in profile
+ assert (local.Team, RDFS.subClassOf, PROV.Organization) in profile
diff --git a/tests/test_prov_o_schema.py b/tests/test_prov_o_schema.py
new file mode 100644
index 000000000..733c96076
--- /dev/null
+++ b/tests/test_prov_o_schema.py
@@ -0,0 +1,261 @@
+"""Real-PostgreSQL contract tests for the PROV-O migration.
+
+The module applies the actual base and PROV-O migration files to a throwaway
+database. It self-skips when no local PostgreSQL is reachable, matching the
+repository's existing real-database schema tests.
+"""
+
+from __future__ import annotations
+
+import os
+import uuid
+from pathlib import Path
+from urllib.parse import urlsplit, urlunsplit
+
+import pytest
+
+psycopg2 = pytest.importorskip("psycopg2")
+sql = pytest.importorskip("psycopg2.sql")
+
+_ADMIN_DSN = os.environ.get(
+ "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres"
+)
+_ROOT = Path(__file__).resolve().parents[1]
+_MIGRATION_PATHS = (
+ _ROOT / "migrations" / "0001_initial_schema.sql",
+ _ROOT / "migrations" / "0017_prov_o_standard_relations.sql",
+)
+
+
+def _postgres_available() -> bool:
+ """Return whether the configured PostgreSQL admin database is reachable."""
+ try:
+ connection = psycopg2.connect(_ADMIN_DSN, connect_timeout=2)
+ connection.close()
+ return True
+ except psycopg2.OperationalError:
+ return False
+
+
+def _dsn_for_database(admin_dsn: str, database_name: str) -> str:
+ """Replace only the database path while preserving DSN query options."""
+ parsed_admin_dsn = urlsplit(admin_dsn)
+ return urlunsplit(parsed_admin_dsn._replace(path=f"/{database_name}"))
+
+
+pytestmark = pytest.mark.skipif(
+ not _postgres_available(),
+ reason=f"no reachable PostgreSQL server at {_ADMIN_DSN}",
+)
+
+
+@pytest.fixture
+def prov_schema_db():
+ """Yield a freshly migrated database and drop it after the test."""
+ database_name = f"lineageweave_prov_{uuid.uuid4().hex[:12]}"
+ admin_connection = psycopg2.connect(_ADMIN_DSN)
+ admin_connection.autocommit = True
+ with admin_connection.cursor() as cursor:
+ cursor.execute(
+ sql.SQL("create database {}").format(sql.Identifier(database_name))
+ )
+ try:
+ database_dsn = _dsn_for_database(_ADMIN_DSN, database_name)
+ connection = psycopg2.connect(database_dsn)
+ try:
+ with connection.cursor() as cursor:
+ for migration_path in _MIGRATION_PATHS:
+ cursor.execute(migration_path.read_text())
+ connection.commit()
+ yield connection
+ finally:
+ connection.close()
+ finally:
+ with admin_connection.cursor() as cursor:
+ cursor.execute(
+ sql.SQL("drop database {}").format(sql.Identifier(database_name))
+ )
+ admin_connection.close()
+
+
+def _resource(cursor, iri: str, class_code: str) -> str:
+ """Insert one typed provenance resource and return its UUID."""
+ cursor.execute(
+ "insert into provenance_resource (resource_iri) values (%s) returning resource_id",
+ (iri,),
+ )
+ resource_id = cursor.fetchone()[0]
+ cursor.execute(
+ "insert into provenance_resource_type (resource_id, class_code) values (%s, %s)",
+ (resource_id, class_code),
+ )
+ return str(resource_id)
+
+
+def test_catalog_has_every_normative_term(prov_schema_db) -> None:
+ """The database catalog exactly matches the Recommendation inventory."""
+ with prov_schema_db.cursor() as cursor:
+ cursor.execute("select count(*) from provenance_class_definition")
+ assert cursor.fetchone()[0] == 30
+ cursor.execute("select count(*) from provenance_relation_definition")
+ assert cursor.fetchone()[0] == 50
+ cursor.execute("select count(*) from provenance_qualification_definition")
+ assert cursor.fetchone()[0] == 14
+ cursor.execute("select count(*) from provenance_inverse_definition")
+ assert cursor.fetchone()[0] == 44
+
+
+def test_database_accepts_valid_generation_and_rejects_wrong_domain(prov_schema_db) -> None:
+ """Recursive class-domain checks are enforced by PostgreSQL itself."""
+ with prov_schema_db.cursor() as cursor:
+ entity_id = _resource(cursor, "urn:test:entity", "prov_entity")
+ activity_id = _resource(cursor, "urn:test:activity", "prov_activity")
+ agent_id = _resource(cursor, "urn:test:agent", "prov_person")
+ cursor.execute(
+ "insert into provenance_assertion "
+ "(subject_resource_id, relation_code, object_resource_id) "
+ "values (%s, 'prov_was_generated_by', %s)",
+ (entity_id, activity_id),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException, match="violates PROV-O domain"):
+ cursor.execute(
+ "insert into provenance_assertion "
+ "(subject_resource_id, relation_code, object_resource_id) "
+ "values (%s, 'prov_was_derived_from', %s)",
+ (agent_id, entity_id),
+ )
+ prov_schema_db.rollback()
+
+
+def test_database_rejects_literal_for_object_property(prov_schema_db) -> None:
+ """Object/datatype shape cannot be bypassed by direct SQL writes."""
+ with prov_schema_db.cursor() as cursor:
+ entity_id = _resource(cursor, "urn:test:shape-entity", "prov_entity")
+ cursor.execute(
+ "insert into provenance_literal_value (lexical_value) values ('bad') returning literal_id"
+ )
+ literal_id = cursor.fetchone()[0]
+ with pytest.raises(psycopg2.errors.RaiseException, match="requires object_resource_id"):
+ cursor.execute(
+ "insert into provenance_assertion "
+ "(subject_resource_id, relation_code, object_literal_id) "
+ "values (%s, 'prov_was_derived_from', %s)",
+ (entity_id, literal_id),
+ )
+ prov_schema_db.rollback()
+
+
+def test_database_requires_xsd_datetime_for_event_time(prov_schema_db) -> None:
+ """Date properties reject untyped lexical strings at the storage boundary."""
+ with prov_schema_db.cursor() as cursor:
+ activity_id = _resource(cursor, "urn:test:time-activity", "prov_activity")
+ cursor.execute(
+ "insert into provenance_literal_value (lexical_value) "
+ "values ('2026-08-14T04:00:00Z') returning literal_id"
+ )
+ literal_id = cursor.fetchone()[0]
+ with pytest.raises(psycopg2.errors.RaiseException, match="violates datatype"):
+ cursor.execute(
+ "insert into provenance_assertion "
+ "(subject_resource_id, relation_code, object_literal_id) "
+ "values (%s, 'prov_started_at_time', %s)",
+ (activity_id, literal_id),
+ )
+ prov_schema_db.rollback()
+
+
+def _literal(cursor, lexical_value: str, datatype_iri: str | None) -> str:
+ """Insert one RDF literal and return its UUID."""
+ cursor.execute(
+ "insert into provenance_literal_value (lexical_value, datatype_iri) "
+ "values (%s, %s) returning literal_id",
+ (lexical_value, datatype_iri),
+ )
+ return str(cursor.fetchone()[0])
+
+
+@pytest.mark.parametrize(
+ "lexical_value",
+ (
+ "2026-08-14T04:00:00",
+ "not-a-date",
+ "2026-02-31T04:00:00Z",
+ "2026-08-14T04:00:00+14:01",
+ ),
+)
+def test_database_rejects_invalid_xsd_datetime(prov_schema_db, lexical_value: str) -> None:
+ """Malformed and timezone-less xsd:dateTime values fail closed."""
+ with prov_schema_db.cursor() as cursor:
+ activity_id = _resource(cursor, "urn:test:strict-time", "prov_activity")
+ literal_id = _literal(
+ cursor,
+ lexical_value,
+ "http://www.w3.org/2001/XMLSchema#dateTime",
+ )
+ with pytest.raises(psycopg2.errors.RaiseException, match="lexical xsd:dateTime"):
+ cursor.execute(
+ "insert into provenance_assertion "
+ "(subject_resource_id, relation_code, object_literal_id) "
+ "values (%s, 'prov_started_at_time', %s)",
+ (activity_id, literal_id),
+ )
+ prov_schema_db.rollback()
+
+
+def test_database_accepts_timezone_aware_xsd_datetime(prov_schema_db) -> None:
+ """A valid timezone-aware dateTime reaches the assertion store."""
+ with prov_schema_db.cursor() as cursor:
+ activity_id = _resource(cursor, "urn:test:valid-time", "prov_activity")
+ literal_id = _literal(
+ cursor,
+ "2026-08-14T04:00:00+09:00",
+ "http://www.w3.org/2001/XMLSchema#dateTime",
+ )
+ cursor.execute(
+ "insert into provenance_assertion "
+ "(subject_resource_id, relation_code, object_literal_id) "
+ "values (%s, 'prov_started_at_time', %s)",
+ (activity_id, literal_id),
+ )
+ prov_schema_db.rollback()
+
+
+def test_referenced_contract_rows_are_immutable(prov_schema_db) -> None:
+ """Reference-table mutation cannot invalidate stored assertions."""
+ with prov_schema_db.cursor() as cursor:
+ entity_id = _resource(cursor, "urn:test:immutable-entity", "prov_entity")
+ activity_id = _resource(cursor, "urn:test:immutable-activity", "prov_activity")
+ cursor.execute(
+ "insert into provenance_assertion "
+ "(subject_resource_id, relation_code, object_resource_id) "
+ "values (%s, 'prov_was_generated_by', %s)",
+ (entity_id, activity_id),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException, match="types are immutable"):
+ cursor.execute(
+ "delete from provenance_resource_type "
+ "where resource_id = %s and class_code = 'prov_activity'",
+ (activity_id,),
+ )
+ prov_schema_db.rollback()
+
+ with prov_schema_db.cursor() as cursor:
+ activity_id = _resource(cursor, "urn:test:immutable-time", "prov_activity")
+ literal_id = _literal(
+ cursor,
+ "2026-08-14T04:00:00Z",
+ "http://www.w3.org/2001/XMLSchema#dateTime",
+ )
+ cursor.execute(
+ "insert into provenance_assertion "
+ "(subject_resource_id, relation_code, object_literal_id) "
+ "values (%s, 'prov_started_at_time', %s)",
+ (activity_id, literal_id),
+ )
+ with pytest.raises(psycopg2.errors.RaiseException, match="literal values are immutable"):
+ cursor.execute(
+ "update provenance_literal_value set datatype_iri = null "
+ "where literal_id = %s",
+ (literal_id,),
+ )
+ prov_schema_db.rollback()
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 33e88f705..324661d08 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -17,6 +17,7 @@
import os
import uuid
from pathlib import Path
+from urllib.parse import urlsplit, urlunsplit
import psycopg2
import psycopg2.errors
@@ -52,7 +53,8 @@ def schema_db():
with admin_conn.cursor() as cur:
cur.execute(f'create database "{db_name}"')
try:
- db_dsn = _ADMIN_DSN.rsplit("/", 1)[0] + f"/{db_name}"
+ parsed_admin_dsn = urlsplit(_ADMIN_DSN)
+ db_dsn = urlunsplit(parsed_admin_dsn._replace(path=f"/{db_name}"))
conn = psycopg2.connect(db_dsn)
try:
with conn.cursor() as cur:
@@ -88,7 +90,9 @@ def test_migration_applies_cleanly(schema_db) -> None:
"cataloged_person",
"person_affiliation",
"post_person_mention",
+ "post_summary_person_mention",
"knowledge_graph_edge",
+ "knowledge_graph_edge_evidence",
"issue_ticket",
"post_lineage_edge",
"post_evaluation_response",
@@ -187,14 +191,38 @@ def test_lookup_code_is_unique_across_categories(schema_db) -> None:
def test_every_created_table_name_has_at_least_two_words() -> None:
- """The project naming rule is enforced on the shipped migration, not
- only on tables that happen to be created in a live-Postgres run.
- """
+ """Enforce naming for ordinary and idempotent table declarations."""
import re
sql = _MIGRATION_PATH.read_text()
- names = re.findall(r"create table (\w+)", sql)
+ names = re.findall(
+ r"create\s+table\s+(?:if\s+not\s+exists\s+)?([a-z][a-z0-9_]*)",
+ sql,
+ flags=re.IGNORECASE,
+ )
assert names, "migration must create at least one table"
for name in names:
words = name.split("_")
assert len(words) >= 2, f"table {name!r} must be two or more snake_case words"
+
+
+def test_cataloged_team_null_affiliation_is_unique(schema_db) -> None:
+ """Repeated NULL-affiliation upserts return one catalog identity."""
+ with schema_db.cursor() as cursor:
+ ids = []
+ for _ in range(2):
+ cursor.execute(
+ "insert into cataloged_team (team_name, affiliated_organization_name) "
+ "values ('Synthetic Design Team', null) "
+ "on conflict (team_name, affiliated_organization_name) do update "
+ "set team_name = excluded.team_name returning team_id"
+ )
+ ids.append(cursor.fetchone()[0])
+ cursor.execute(
+ "select count(*) from cataloged_team "
+ "where team_name = 'Synthetic Design Team' "
+ "and affiliated_organization_name is null"
+ )
+ count = cursor.fetchone()[0]
+ assert ids[0] == ids[1]
+ assert count == 1
diff --git a/tests/test_seed_tepp_run.py b/tests/test_seed_tepp_run.py
new file mode 100644
index 000000000..b25908cbe
--- /dev/null
+++ b/tests/test_seed_tepp_run.py
@@ -0,0 +1,132 @@
+"""Seeded TEPP analysis runs go through tepp_client, never a local model."""
+
+from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable
+from scripts.seed_demo_data import (
+ _ensure_demo_source_counts,
+ _seed_demo_tepp_run,
+ demo_source_snapshot_sha256,
+ tepp_seed_outcome,
+ tepp_seed_request,
+)
+
+
+class _RecordingUnavailableClient(TeppClient):
+ """Default-path stand-in that records the request then drops the channel."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.submitted: list[AnalysisRunRequest] = []
+
+ def submit_analysis_run(self, request: AnalysisRunRequest) -> dict[str, object]:
+ self.submitted.append(request)
+ raise TeppNotAvailable("TEPP has no live HTTP endpoint yet.")
+
+
+class _AcceptingClient(TeppClient):
+ """Transport that returns an envelope without a persistable measurement."""
+
+ def __init__(self) -> None:
+ super().__init__(transport=lambda _payload: {"status": "accepted"})
+
+
+class _CountCursor:
+ """Minimal cursor for proving re-seed skips a frozen count insert."""
+
+ def __init__(self, existing_counts: bool) -> None:
+ self.existing_counts = existing_counts
+ self.statements: list[str] = []
+
+ def execute(self, sql: str, _params=None) -> None:
+ self.statements.append(" ".join(sql.split()))
+
+ def fetchone(self):
+ if self.existing_counts and "from analysis_source_count" in self.statements[-1]:
+ return (1,)
+ return None
+
+
+def test_tepp_seed_request_targets_the_shared_demo_snapshot() -> None:
+ request = tepp_seed_request()
+ assert request.snapshot_id == demo_source_snapshot_sha256()
+ assert request.idempotency_key == "demo-tepp-seed-2026-w02"
+ assert request.model_contract_version == "tepp-analysis-run-v1"
+ assert request.output_profile == "calibrated_event_measurement"
+
+
+def test_tepp_seed_outcome_calls_client_and_does_not_invent_a_score() -> None:
+ client = _RecordingUnavailableClient()
+ status, failure = tepp_seed_outcome(client)
+ assert status == "analysis_status_failed"
+ assert failure == "tepp_not_available"
+ assert client.submitted == [tepp_seed_request()]
+
+
+def test_tepp_seed_outcome_default_client_is_unavailable_not_a_fake_score() -> None:
+ status, failure = tepp_seed_outcome()
+ assert status == "analysis_status_failed"
+ assert failure == "tepp_not_available"
+
+
+def test_tepp_seed_outcome_does_not_treat_an_empty_envelope_as_success() -> None:
+ status, failure = tepp_seed_outcome(_AcceptingClient())
+ assert status == "analysis_status_failed"
+ assert failure == "tepp_result_not_persisted"
+
+
+def test_ensure_demo_source_counts_skips_insert_when_counts_exist() -> None:
+ cursor = _CountCursor(existing_counts=True)
+ _ensure_demo_source_counts(cursor, "snapshot-1")
+ assert any("from analysis_source_count" in sql for sql in cursor.statements)
+ assert not any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements)
+
+
+def test_ensure_demo_source_counts_inserts_when_the_snapshot_is_empty() -> None:
+ cursor = _CountCursor(existing_counts=False)
+ _ensure_demo_source_counts(cursor, "snapshot-1")
+ assert any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements)
+
+
+class _TeppSeedCursor:
+ """Drive `_seed_demo_tepp_run` without a live database."""
+
+ def __init__(self) -> None:
+ self.statements: list[str] = []
+ self.params: list[object] = []
+
+ def execute(self, sql: str, params=None) -> None:
+ self.statements.append(" ".join(sql.split()))
+ self.params.append(params)
+
+ def fetchone(self):
+ last = self.statements[-1]
+ if last.lstrip().startswith("select") and "from analysis_source_snapshot" in last:
+ return None
+ if "insert into analysis_source_snapshot" in last:
+ return ("snapshot-demo",)
+ if last.lstrip().startswith("select") and "from analysis_source_count" in last:
+ return None
+ if last.lstrip().startswith("select") and "from analysis_run" in last:
+ return None
+ if "insert into analysis_run" in last:
+ return ("run-demo-tepp",)
+ return None
+
+
+def test_seed_demo_tepp_run_inserts_failed_tepp_not_available() -> None:
+ cursor = _TeppSeedCursor()
+ _seed_demo_tepp_run(cursor, "account-1", "corp-1")
+ run_inserts = [sql for sql in cursor.statements if "insert into analysis_run" in sql]
+ assert run_inserts, "seed must insert the TEPP analysis_run row"
+ assert any("analysis_run_tepp" in sql for sql in run_inserts)
+ status_params = [
+ params
+ for sql, params in zip(cursor.statements, cursor.params, strict=True)
+ if "insert into analysis_run_status_event" in sql
+ ]
+ assert any(
+ params is not None and "analysis_status_failed" in params and "tepp_not_available" in params
+ for params in status_params
+ )
+ assert not any(
+ params is not None and "analysis_status_succeeded" in params for params in status_params
+ )
diff --git a/uv.lock b/uv.lock
index 1964f34b9..6915a3531 100644
--- a/uv.lock
+++ b/uv.lock
@@ -188,6 +188,105 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "coverage"
+version = "7.15.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" },
+ { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" },
+ { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" },
+ { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" },
+ { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" },
+ { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" },
+ { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" },
+ { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" },
+ { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" },
+ { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" },
+ { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" },
+ { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" },
+ { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" },
+ { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" },
+ { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" },
+ { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" },
+ { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" },
+ { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" },
+ { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" },
+ { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" },
+ { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" },
+ { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" },
+ { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" },
+ { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" },
+ { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" },
+ { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" },
+ { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" },
+ { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" },
+ { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" },
+ { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" },
+ { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" },
+ { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" },
+ { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" },
+ { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" },
+ { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" },
+ { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" },
+ { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" },
+ { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" },
+]
+
[[package]]
name = "cryptography"
version = "50.0.0"
@@ -355,7 +454,7 @@ wheels = [
[[package]]
name = "lineageweave"
-version = "0.71.0"
+version = "0.87.0"
source = { virtual = "." }
dependencies = [
{ name = "certifi" },
@@ -374,6 +473,7 @@ backend = [
{ name = "uvicorn", extra = ["standard"] },
]
dev = [
+ { name = "coverage" },
{ name = "httpx" },
{ name = "pillow" },
{ name = "psycopg2-binary" },
@@ -385,6 +485,7 @@ dev = [
requires-dist = [
{ name = "asyncpg", marker = "extra == 'backend'", specifier = ">=0.29.0" },
{ name = "certifi", specifier = ">=2024.0.0" },
+ { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" },
{ name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=5006c38286a4fa1d81bcf57eeed5ce27ae743f50" },
{ name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.115.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },