diff --git a/backend/app/project_history.py b/backend/app/project_history.py
index 9389ef5fd..d203fb8c4 100644
--- a/backend/app/project_history.py
+++ b/backend/app/project_history.py
@@ -133,8 +133,31 @@ async def fetch(self, query: str, *args: object) -> Sequence[Mapping[str, Any]]:
order by role.post_id, role.actor_type_code, role.actor_name, role.responsibility
"""
_EDGE_SQL = """
-select edge.parent_post_id, edge.child_post_id, edge.fused_score
+select edge.parent_post_id, edge.child_post_id, edge.fused_score,
+ temporal.observed as temporal_observed,
+ temporal.allen_relations,
+ temporal.artifact_digest_sha256
from post_lineage_edge edge
+ left join lateral (
+ select relation.observed,
+ array_agg(kind.relation_code order by kind.relation_ordinal) as allen_relations,
+ artifact.artifact_digest_sha256
+ from project_journey_temporal_relation relation
+ join project_journey_temporal_artifact artifact
+ on artifact.analysis_run_id = relation.analysis_run_id
+ join analysis_run temporal_run
+ on temporal_run.analysis_run_id = artifact.analysis_run_id
+ join project_journey_temporal_relation_kind kind
+ on kind.analysis_run_id = relation.analysis_run_id
+ and kind.left_post_id = relation.left_post_id
+ and kind.right_post_id = relation.right_post_id
+ where relation.left_post_id = edge.parent_post_id
+ and relation.right_post_id = edge.child_post_id
+ and temporal_run.knowledge_cutoff <= $2
+ group by relation.observed, artifact.artifact_digest_sha256, artifact.admitted_at
+ order by artifact.admitted_at desc, artifact.artifact_digest_sha256 desc
+ limit 1
+ ) temporal on true
where edge.parent_post_id = any($1::uuid[])
and edge.child_post_id = any($1::uuid[])
order by edge.child_post_id, edge.parent_post_id
@@ -219,6 +242,7 @@ async def fetch_project_history_projection(
conn,
visible_ids=visible_ids,
normalized_key=normalized_key,
+ knowledge_cutoff=knowledge_cutoff,
)
return build_project_history_projection(
project_key=project_key,
@@ -237,10 +261,11 @@ async def _fetch_project_children(
*,
visible_ids: Sequence[str],
normalized_key: str,
+ knowledge_cutoff: datetime,
) -> tuple[list[Mapping[str, Any]], list[Mapping[str, Any]], list[Mapping[str, Any]]]:
"""Fetch only child evidence whose endpoints are already authorized."""
matches = list(await conn.fetch(_MATCH_SQL, list(visible_ids), normalized_key))
roles = list(await conn.fetch(_ROLE_SQL, list(visible_ids)))
- edges = list(await conn.fetch(_EDGE_SQL, list(visible_ids)))
+ edges = list(await conn.fetch(_EDGE_SQL, list(visible_ids), knowledge_cutoff))
return matches, roles, edges
diff --git a/backend/app/project_journey_temporal.py b/backend/app/project_journey_temporal.py
new file mode 100644
index 000000000..0d3cf214d
--- /dev/null
+++ b/backend/app/project_journey_temporal.py
@@ -0,0 +1,117 @@
+"""Persist provider-owned temporal evidence for already-admitted journey edges."""
+
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+from lineageweave.temporal_journey_artifact import (
+ ALLEN_RELATIONS,
+ TemporalJourneyArtifact,
+ parse_temporal_journey_artifact,
+)
+
+
+class TemporalArtifactConnection(Protocol):
+ """Minimal transaction-scoped database port for artifact admission."""
+
+ async def fetchrow(self, query: str, *args: object) -> Any:
+ """Read one binding row."""
+
+ ...
+
+ async def execute(self, query: str, *args: object) -> Any:
+ """Execute one immutable persistence statement."""
+
+ ...
+
+ async def executemany(self, query: str, args: list[tuple[object, ...]]) -> Any:
+ """Execute bounded normalized child inserts."""
+
+ ...
+
+
+class TemporalArtifactAdmissionError(ValueError):
+ """The artifact cannot be bound to the declared persisted run."""
+
+
+async def persist_project_journey_temporal_artifact(
+ conn: TemporalArtifactConnection,
+ *,
+ analysis_run_id: str,
+ payload: bytes,
+ expected_run_id: str,
+ expected_snapshot_id: str,
+ expected_input_digest_sha256: str,
+ expected_artifact_digest_sha256: str,
+) -> TemporalJourneyArtifact:
+ """Validate and immutably persist temporal evidence for existing edges.
+
+ The foreign key to ``post_lineage_edge`` is the semantic admission gate:
+ interval order can corroborate an admitted predecessor, but cannot create
+ a predecessor, branch, responsibility handoff, or causal transition.
+ """
+
+ artifact = parse_temporal_journey_artifact(
+ payload,
+ expected_run_id=expected_run_id,
+ expected_snapshot_id=expected_snapshot_id,
+ expected_input_digest_sha256=expected_input_digest_sha256,
+ expected_artifact_digest_sha256=expected_artifact_digest_sha256,
+ )
+ binding = await conn.fetchrow(
+ "select remote_run_id from analysis_run_tepp_result where analysis_run_id = $1::uuid",
+ analysis_run_id,
+ )
+ if binding is None or str(binding["remote_run_id"]) != expected_run_id:
+ raise TemporalArtifactAdmissionError("artifact run does not match a persisted terminal result")
+ existing = await conn.fetchrow(
+ "select artifact_digest_sha256 from project_journey_temporal_artifact "
+ "where analysis_run_id = $1::uuid for update",
+ analysis_run_id,
+ )
+ if existing is not None:
+ if str(existing["artifact_digest_sha256"]) != expected_artifact_digest_sha256:
+ raise TemporalArtifactAdmissionError("analysis run already has a different artifact")
+ return artifact
+ await conn.execute(
+ "insert into project_journey_temporal_artifact "
+ "(analysis_run_id, remote_run_id, schema_version, snapshot_id, input_digest_sha256, artifact_digest_sha256) "
+ "values ($1::uuid, $2, $3, $4, $5, $6)",
+ analysis_run_id,
+ expected_run_id,
+ "tepp.tdt_chronos_interval_consistency.v1",
+ expected_snapshot_id,
+ expected_input_digest_sha256,
+ expected_artifact_digest_sha256,
+ )
+ relation_rows = [
+ (analysis_run_id, relation.left_event_id, relation.right_event_id, relation.observed)
+ for relation in artifact.relations
+ ]
+ await conn.executemany(
+ "insert into project_journey_temporal_relation "
+ "(analysis_run_id, left_post_id, right_post_id, observed) "
+ "values ($1::uuid, $2::uuid, $3::uuid, $4)",
+ relation_rows,
+ )
+ await conn.executemany(
+ "insert into project_journey_temporal_relation_kind "
+ "(analysis_run_id, left_post_id, right_post_id, relation_code, relation_ordinal) "
+ "values ($1::uuid, $2::uuid, $3::uuid, $4, $5)",
+ [
+ (analysis_run_id, relation.left_event_id, relation.right_event_id, code, ALLEN_RELATIONS.index(code))
+ for relation in artifact.relations
+ for code in relation.allen_relations
+ ],
+ )
+ await conn.executemany(
+ "insert into project_journey_temporal_support "
+ "(analysis_run_id, left_post_id, right_post_id, assertion_ordinal) "
+ "values ($1::uuid, $2::uuid, $3::uuid, $4)",
+ [
+ (analysis_run_id, relation.left_event_id, relation.right_event_id, ordinal)
+ for relation in artifact.relations
+ for ordinal in relation.support_assertion_ordinals
+ ],
+ )
+ return artifact
diff --git a/docs/adr/0270-digest-bound-project-journey-temporal-evidence.md b/docs/adr/0270-digest-bound-project-journey-temporal-evidence.md
new file mode 100644
index 000000000..70548e445
--- /dev/null
+++ b/docs/adr/0270-digest-bound-project-journey-temporal-evidence.md
@@ -0,0 +1,61 @@
+# ADR 0270: Digest-bound project-journey temporal evidence
+
+- Status: Accepted on this stacked branch; not protected-main truth until merge
+- Date: 2026-08-28
+- Depends on: ADR 0132, ADR 0231, ADR 0243; TEPP PR #291
+- Figma file ID: `SBpgot7uTvMxEaxUwvoc0S`
+
+## Context
+
+TEPP PR #291 publishes canonical JSON and GraphML for bounded Allen interval-
+consistency results. The artifact binds a run, snapshot, exact input digest,
+ordered event pair, observed/derived status, and supporting assertion ordinals.
+It deliberately does not claim that temporal order is a causal transition,
+project predecessor, or business-process branch.
+
+LineageWeave already admits related predecessor paths through authorized
+`post_lineage_edge` evidence. Promoting every temporally ordered pair to a
+project journey would contradict PRD-FR-5E and ADR 0243.
+
+## Decision
+
+LineageWeave accepts only canonical artifact bytes whose SHA-256, run,
+snapshot, and exact input digest match caller-computed expected values. The
+remote run must also match a persisted terminal TEPP result. Metadata,
+relations, elementary Allen kinds, and support ordinals persist in normalized
+tables.
+
+Every admitted temporal pair must already be an exact `post_lineage_edge`.
+The database foreign key enforces that boundary. Temporal evidence may
+corroborate the time order of an existing related-history path; it never
+creates a predecessor, branch, responsibility handoff, or causal transition.
+A branch is visible only when the independently admitted lineage graph already
+contains that topology. A transition still requires its separately governed
+observed business or responsibility evidence.
+
+The Project History API attaches the newest immutable temporal evidence whose
+analysis cutoff does not exceed the requested view cutoff to the corresponding
+visible edge after ABAC selects both endpoints. The customer UI says what the user can do next—open the supporting
+records and compare dates—and never names the calculation module.
+
+GraphML is an equivalent provider export, not the ingestion authority. The
+canonical typed JSON is the sole admitted payload so two representations
+cannot diverge inside the database.
+
+## Consequences
+
+- Exact temporal consistency becomes durable and auditable without duplicating
+ mathematical reasoning in Python.
+- A valid artifact containing a pair absent from Event Lineage fails closed at
+ the foreign-key boundary and rolls back its transaction.
+- A future contract that explicitly carries business predecessor or transition
+ semantics requires a new ADR; this artifact cannot be reinterpreted later.
+
+## References
+
+Allen, J. F. (1983). Maintaining knowledge about temporal intervals.
+*Communications of the ACM, 26*(11), 832–843.
+https://doi.org/10.1145/182.358434
+
+World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*.
+https://www.w3.org/TR/prov-o/
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 2aedc44e0..649192595 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -28,6 +28,7 @@ decision from them.
| [`operability/http-concurrency-evidence.md`](../operability/http-concurrency-evidence.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0212](0212-single-query-authorized-post-filter-options.md), [0213](0213-global-ask-embedding-pool-release.md) |
| [`operability/mcp-concurrency-evidence.md`](../operability/mcp-concurrency-evidence.md) | [0218](0218-current-contract-mcp-global-ask.md) |
| Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) |
+| Project-journey temporal evidence | [0270](0270-digest-bound-project-journey-temporal-evidence.md) |
| [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) |
| [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) |
| [`WORKER_FUNCTION_TAXONOMY_REFERENCES.md`](../doctoring/WORKER_FUNCTION_TAXONOMY_REFERENCES.md) | [0232](0232-worker-function-taxonomy-in-the-published-ontology.md) |
diff --git a/docs/product-requirements.md b/docs/product-requirements.md
index 305da5704..5ee3f028c 100644
--- a/docs/product-requirements.md
+++ b/docs/product-requirements.md
@@ -459,6 +459,9 @@ comes from the cited answer rather than frontend inference.
- Present project-specific journeys only from accepted evidence-bearing
predecessor and branch relations. A timestamp sort may be labeled observed
events, but never promoted to a journey.
+- Attach digest-bound interval-consistency evidence only to an already
+ admitted predecessor edge. Temporal order alone never creates a predecessor,
+ branch, responsibility handoff, or causal transition (ADR 0270).
Acceptance: every populated fact, lifecycle endpoint, membership, and journey
event opens an authorized evidence post; an incomplete provenance chain fails
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index f4041e314..0f14ac724 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -836,7 +836,7 @@ post-merge reruns (not transferable evidence for later heads):
| ---: | --- | --- |
| #643 | Shared StatusNotice (ADR 0220): success/unavailable/retry states, WorkspaceCalendar auth-unavailable copy, 5-locale i18n; CI Full suite 22m54s green | ADR 0220 |
| #644 | Native workspace surface split: 9 conditionally rendered components as lazy() dynamic imports behind a SurfaceBoundary error boundary; build emits 9 chunks (1.5-37 kB), main bundle 543 kB; 470 frontend tests, tsc, Storybook green | — |
-| #762 | Evidence-bound project history (ADR 0243): /api/projects/{key}/history endpoint, project_history.py projection, fetchProjectHistory client, standalone ProjectHistoryTimeline component; supersedes #668 (3-way merge kept only the additive +2279/-0, dropping the branch's 8k shared-file reverts; popup UI hookup deferred as a scoped follow-up) | ADR 0243 |
+| #762 | Evidence-bound project history (ADR 0243): /api/projects/{key}/history endpoint, project_history.py projection, fetchProjectHistory client, standalone ProjectHistoryTimeline component; supersedes #668 (3-way merge kept only the additive +2279/-0, dropping the branch's 8k shared-file reverts; popup UI hookup deferred as a scoped follow-up). ADR 0270 successor work admits digest-bound interval evidence only for existing lineage edges; it does not promote time order to a business transition. | ADR 0243, ADR 0270 |
| #763 | Live-PostgreSQL A→B→A Voice history validation (ADR 0252) proving effective_from/effective_to interval replacement across repeated primary-Voice imports | ADR 0252 |
| #764 | Test-only coverage lift: observability 78%→96%, post_summary 77%→89%, claim_verification 86%→99%; package line coverage 93.5%→95% (484→371 missing); 1651 Python tests green | — |
| #761 | Temporal imported-primary Voice history (ADR 0252): migration 0243 (`effective_to` + GiST primary-period exclusion + synchronize trigger), refined 0237 `least()` effective_from backfill, `effective_from/effective_to` dataclass/export + `coalesce($2,$3)` cutoff predicate. Completes the half-shipped main layer that queried `voice.effective_to` against a missing column. CI Full suite 19m13s green | ADR 0252 |
diff --git a/frontend/src/components/ProjectHistoryTimeline.test.tsx b/frontend/src/components/ProjectHistoryTimeline.test.tsx
index c58d54efb..afb921cba 100644
--- a/frontend/src/components/ProjectHistoryTimeline.test.tsx
+++ b/frontend/src/components/ProjectHistoryTimeline.test.tsx
@@ -87,7 +87,16 @@ const projection: ProjectHistoryProjection = {
target_event_id: "voc",
event_ids: ["award", "spec", "voc"],
edges: [
- { parent_event_id: "award", child_event_id: "spec", fused_score: 0.91 },
+ {
+ parent_event_id: "award",
+ child_event_id: "spec",
+ fused_score: 0.91,
+ temporal_evidence: {
+ truth_status_code: "inferred",
+ interval_relations: ["before"],
+ artifact_digest_sha256: "a".repeat(64),
+ },
+ },
{ parent_event_id: "spec", child_event_id: "voc", fused_score: 0.73 },
],
minimum_fused_score: 0.73,
@@ -114,6 +123,7 @@ describe("ProjectHistoryTimeline", () => {
expect(screen.queryByText("document_time")).not.toBeInTheDocument();
expect(screen.getByText("delivery")).toBeInTheDocument();
expect(screen.getByText("delivered")).toBeInTheDocument();
+ expect(screen.getByText(/Time order checked/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /open source record: VOC received/i }));
expect(onOpenPost).toHaveBeenCalledWith("post-voc");
diff --git a/frontend/src/components/ProjectHistoryTimeline.tsx b/frontend/src/components/ProjectHistoryTimeline.tsx
index e0c2a1f2e..a9b994d09 100644
--- a/frontend/src/components/ProjectHistoryTimeline.tsx
+++ b/frontend/src/components/ProjectHistoryTimeline.tsx
@@ -259,6 +259,11 @@ export function ProjectHistoryTimeline({
{projectHistoryText(locale, "inferred")}
+ {path.edges.some((edge) => edge.temporal_evidence != null) ? (
+
+ {projectHistoryText(locale, "timeOrderChecked")}
+
+ ) : null}
))}
diff --git a/frontend/src/projectHistory.ts b/frontend/src/projectHistory.ts
index 4dceb6fbd..d0dacb3f3 100644
--- a/frontend/src/projectHistory.ts
+++ b/frontend/src/projectHistory.ts
@@ -28,6 +28,11 @@ export interface ProjectHistoryPathEdge {
parent_event_id: string;
child_event_id: string;
fused_score: number;
+ temporal_evidence?: {
+ truth_status_code: ProjectHistoryTruthStatus;
+ interval_relations: string[];
+ artifact_digest_sha256: string;
+ } | null;
}
export interface ProjectHistoryPriorPath {
@@ -113,6 +118,7 @@ const MESSAGE_KEYS = [
"priorHistory",
"noPriorHistory",
"inferredBoundary",
+ "timeOrderChecked",
"projectEvidence",
"sourceRecordEvidence",
"supportingRecordEvidence",
@@ -164,6 +170,7 @@ const EN: Record = {
priorHistory: "Related prior history",
noPriorHistory: "No visible prior lineage path is recorded for this event.",
inferredBoundary: "This is inferred related history, not causality or an authoritative assignment record.",
+ timeOrderChecked: "Time order checked. Open the records above to compare the supporting dates.",
projectEvidence: "Project identity evidence",
sourceRecordEvidence: "Source record",
supportingRecordEvidence: "Supporting record",
@@ -212,6 +219,7 @@ const MESSAGES: Record> = {
priorHistory: "관련 과거 이력",
noPriorHistory: "이 이벤트로 이어지는 공개 가능한 이전 계보가 없습니다.",
inferredBoundary: "이는 추론된 관련 이력이며 인과관계나 권위 있는 인사 배정 기록이 아닙니다.",
+ timeOrderChecked: "시간 순서를 확인했습니다. 위 기록을 열어 근거 날짜를 비교하세요.",
projectEvidence: "프로젝트 식별 근거",
sourceRecordEvidence: "원천 기록",
supportingRecordEvidence: "뒷받침 기록",
@@ -257,6 +265,7 @@ const MESSAGES: Record> = {
priorHistory: "相关既往历史",
noPriorHistory: "此事件没有可见的既往谱系路径。",
inferredBoundary: "这是推断的相关历史,并非因果关系或权威任命记录。",
+ timeOrderChecked: "时间顺序已核验。请打开上方记录比较依据日期。",
projectEvidence: "项目身份依据",
sourceRecordEvidence: "来源记录",
supportingRecordEvidence: "支持记录",
@@ -302,6 +311,7 @@ const MESSAGES: Record> = {
priorHistory: "関連する過去履歴",
noPriorHistory: "このイベントに至る可視の過去系譜はありません。",
inferredBoundary: "これは推論された関連履歴であり、因果関係や権威ある配属記録ではありません。",
+ timeOrderChecked: "時間順序を確認しました。上の記録を開いて根拠の日付を比較してください。",
projectEvidence: "プロジェクト識別根拠",
sourceRecordEvidence: "元レコード",
supportingRecordEvidence: "根拠レコード",
@@ -347,6 +357,7 @@ const MESSAGES: Record> = {
priorHistory: "Lịch sử trước đó có liên quan",
noPriorHistory: "Không có đường dẫn lịch sử trước đó khả kiến cho sự kiện này.",
inferredBoundary: "Đây là lịch sử liên quan được suy luận, không phải quan hệ nhân quả hay hồ sơ phân công có thẩm quyền.",
+ timeOrderChecked: "Thứ tự thời gian đã được kiểm tra. Hãy mở các bản ghi trên để so sánh ngày làm căn cứ.",
projectEvidence: "Bằng chứng nhận dạng dự án",
sourceRecordEvidence: "Bản ghi nguồn",
supportingRecordEvidence: "Bản ghi hỗ trợ",
diff --git a/lineageweave/project_history.py b/lineageweave/project_history.py
index 281586b03..04db6ed4f 100644
--- a/lineageweave/project_history.py
+++ b/lineageweave/project_history.py
@@ -148,6 +148,17 @@ def _prior_paths(
"parent_event_id": parent,
"child_event_id": child,
"fused_score": _score(row["fused_score"]),
+ "temporal_evidence": (
+ {
+ "truth_status_code": (
+ "observed" if row.get("temporal_observed") else "inferred"
+ ),
+ "interval_relations": list(row.get("allen_relations") or ()),
+ "artifact_digest_sha256": row.get("artifact_digest_sha256"),
+ }
+ if row.get("artifact_digest_sha256") is not None
+ else None
+ ),
}
)
for edges in reverse_edges.values():
diff --git a/lineageweave/temporal_journey_artifact.py b/lineageweave/temporal_journey_artifact.py
new file mode 100644
index 000000000..ef54513a1
--- /dev/null
+++ b/lineageweave/temporal_journey_artifact.py
@@ -0,0 +1,119 @@
+"""Validate TEPP interval-consistency artifacts without inventing journeys."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from dataclasses import dataclass
+from typing import Final
+
+SCHEMA_VERSION: Final = "tepp.tdt_chronos_interval_consistency.v1"
+MAX_ARTIFACT_BYTES: Final = 4 * 1024 * 1024
+MAX_RELATIONS: Final = 100_000
+ALLEN_RELATIONS: Final = (
+ "before", "after", "meets", "met_by", "overlaps", "overlapped_by",
+ "starts", "started_by", "during", "contains", "finishes", "finished_by", "equals",
+)
+_DIGEST = re.compile(r"^[0-9a-f]{64}$")
+
+
+class TemporalJourneyArtifactError(ValueError):
+ """A fail-closed temporal-artifact contract violation."""
+
+
+@dataclass(frozen=True)
+class TemporalRelation:
+ """One bounded observed or closure-derived interval relation."""
+
+ left_event_id: str
+ right_event_id: str
+ allen_relations: tuple[str, ...]
+ observed: bool
+ support_assertion_ordinals: tuple[int, ...]
+
+
+@dataclass(frozen=True)
+class TemporalJourneyArtifact:
+ """A canonical digest-bound interval-consistency artifact."""
+
+ run_id: str
+ snapshot_id: str
+ input_digest_sha256: str
+ relations: tuple[TemporalRelation, ...]
+ artifact_digest_sha256: str
+
+
+def parse_temporal_journey_artifact(
+ payload: bytes,
+ *,
+ expected_run_id: str,
+ expected_snapshot_id: str,
+ expected_input_digest_sha256: str,
+ expected_artifact_digest_sha256: str,
+) -> TemporalJourneyArtifact:
+ """Parse canonical provider JSON and bind every caller-owned identity."""
+
+ if not payload or len(payload) > MAX_ARTIFACT_BYTES:
+ raise TemporalJourneyArtifactError("artifact size is outside the supported bound")
+ if not all(
+ _DIGEST.fullmatch(value)
+ for value in (expected_input_digest_sha256, expected_artifact_digest_sha256)
+ ):
+ raise TemporalJourneyArtifactError("expected digest is not lowercase SHA-256")
+ if hashlib.sha256(payload).hexdigest() != expected_artifact_digest_sha256:
+ raise TemporalJourneyArtifactError("artifact bytes do not match the expected digest")
+ try:
+ decoded = payload.decode("utf-8")
+ value = json.loads(decoded)
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise TemporalJourneyArtifactError("artifact is not valid UTF-8 JSON") from exc
+ if json.dumps(value, ensure_ascii=False, separators=(",", ":")) != decoded:
+ raise TemporalJourneyArtifactError("artifact JSON is not canonical")
+ if not isinstance(value, dict) or set(value) != {
+ "schema_version", "run_id", "snapshot_id", "input_digest_sha256", "relations"
+ }:
+ raise TemporalJourneyArtifactError("artifact object shape is unsupported")
+ if (
+ value["schema_version"] != SCHEMA_VERSION
+ or value["run_id"] != expected_run_id
+ or value["snapshot_id"] != expected_snapshot_id
+ or value["input_digest_sha256"] != expected_input_digest_sha256
+ ):
+ raise TemporalJourneyArtifactError("artifact identity does not match the admitted run")
+ raw_relations = value["relations"]
+ if not isinstance(raw_relations, list) or not 1 <= len(raw_relations) <= MAX_RELATIONS:
+ raise TemporalJourneyArtifactError("relation count is outside the supported bound")
+ parsed: list[TemporalRelation] = []
+ previous: tuple[str, str] | None = None
+ for item in raw_relations:
+ if not isinstance(item, dict) or set(item) != {
+ "left_event_id", "right_event_id", "allen_relations", "observed",
+ "support_assertion_ordinals",
+ }:
+ raise TemporalJourneyArtifactError("relation object shape is unsupported")
+ left, right = item["left_event_id"], item["right_event_id"]
+ relations, support = item["allen_relations"], item["support_assertion_ordinals"]
+ key = (left, right) if isinstance(left, str) and isinstance(right, str) else ("", "")
+ if (
+ not key[0].strip() or not key[1].strip() or key[0] == key[1]
+ or previous is not None and previous >= key
+ or not isinstance(item["observed"], bool)
+ or not isinstance(relations, list) or not relations
+ or any(relation not in ALLEN_RELATIONS for relation in relations)
+ or relations != sorted(set(relations), key=ALLEN_RELATIONS.index)
+ or len(relations) == len(ALLEN_RELATIONS)
+ or not isinstance(support, list) or not support
+ or any(isinstance(ordinal, bool) or not isinstance(ordinal, int) or ordinal < 0 for ordinal in support)
+ or support != sorted(set(support))
+ ):
+ raise TemporalJourneyArtifactError("relation value is invalid or noncanonical")
+ parsed.append(TemporalRelation(key[0], key[1], tuple(relations), item["observed"], tuple(support)))
+ previous = key
+ return TemporalJourneyArtifact(
+ expected_run_id,
+ expected_snapshot_id,
+ expected_input_digest_sha256,
+ tuple(parsed),
+ expected_artifact_digest_sha256,
+ )
diff --git a/migrations/0259_project_journey_temporal_artifact.sql b/migrations/0259_project_journey_temporal_artifact.sql
new file mode 100644
index 000000000..4529be923
--- /dev/null
+++ b/migrations/0259_project_journey_temporal_artifact.sql
@@ -0,0 +1,52 @@
+-- Digest-bound temporal evidence admitted only for existing Event Lineage edges.
+create table if not exists project_journey_temporal_artifact (
+ analysis_run_id uuid primary key references analysis_run_tepp_result(analysis_run_id) on delete cascade,
+ remote_run_id text not null,
+ schema_version text not null check (schema_version = 'tepp.tdt_chronos_interval_consistency.v1'),
+ snapshot_id text not null check (btrim(snapshot_id) <> ''),
+ input_digest_sha256 text not null check (input_digest_sha256 ~ '^[0-9a-f]{64}$'),
+ artifact_digest_sha256 text not null unique check (artifact_digest_sha256 ~ '^[0-9a-f]{64}$'),
+ admitted_at timestamptz not null default clock_timestamp(),
+ unique (analysis_run_id, remote_run_id)
+);
+
+create table if not exists project_journey_temporal_relation (
+ analysis_run_id uuid not null references project_journey_temporal_artifact(analysis_run_id) on delete cascade,
+ left_post_id uuid not null references source_post(post_id) on delete cascade,
+ right_post_id uuid not null references source_post(post_id) on delete cascade,
+ observed boolean not null,
+ primary key (analysis_run_id, left_post_id, right_post_id),
+ foreign key (left_post_id, right_post_id)
+ references post_lineage_edge(parent_post_id, child_post_id) on delete cascade,
+ check (left_post_id <> right_post_id)
+);
+
+create table if not exists project_journey_temporal_relation_kind (
+ analysis_run_id uuid not null,
+ left_post_id uuid not null,
+ right_post_id uuid not null,
+ relation_code text not null check (relation_code in (
+ 'before', 'after', 'meets', 'met_by', 'overlaps', 'overlapped_by',
+ 'starts', 'started_by', 'during', 'contains', 'finishes', 'finished_by', 'equals'
+ )),
+ relation_ordinal smallint not null check (relation_ordinal between 0 and 12),
+ primary key (analysis_run_id, left_post_id, right_post_id, relation_code),
+ unique (analysis_run_id, left_post_id, right_post_id, relation_ordinal),
+ foreign key (analysis_run_id, left_post_id, right_post_id)
+ references project_journey_temporal_relation(analysis_run_id, left_post_id, right_post_id)
+ on delete cascade
+);
+
+create table if not exists project_journey_temporal_support (
+ analysis_run_id uuid not null,
+ left_post_id uuid not null,
+ right_post_id uuid not null,
+ assertion_ordinal integer not null check (assertion_ordinal >= 0),
+ primary key (analysis_run_id, left_post_id, right_post_id, assertion_ordinal),
+ foreign key (analysis_run_id, left_post_id, right_post_id)
+ references project_journey_temporal_relation(analysis_run_id, left_post_id, right_post_id)
+ on delete cascade
+);
+
+create index if not exists project_journey_temporal_relation_right_idx
+ on project_journey_temporal_relation (right_post_id, left_post_id, analysis_run_id);
diff --git a/tests/test_project_history.py b/tests/test_project_history.py
index 8eedec3fe..8b716fd1d 100644
--- a/tests/test_project_history.py
+++ b/tests/test_project_history.py
@@ -57,7 +57,14 @@ def test_prior_paths_keep_the_first_deterministic_shortest_route_per_predecessor
paths = _prior_paths(
["award", "spec-a", "spec-b", "delivery"],
[
- {"parent_post_id": "award", "child_post_id": "spec-a", "fused_score": 0.9},
+ {
+ "parent_post_id": "award",
+ "child_post_id": "spec-a",
+ "fused_score": 0.9,
+ "temporal_observed": True,
+ "allen_relations": ["before"],
+ "artifact_digest_sha256": "a" * 64,
+ },
{"parent_post_id": "award", "child_post_id": "spec-b", "fused_score": 0.8},
{"parent_post_id": "spec-a", "child_post_id": "delivery", "fused_score": 0.7},
{"parent_post_id": "spec-b", "child_post_id": "delivery", "fused_score": 0.6},
@@ -68,3 +75,9 @@ def test_prior_paths_keep_the_first_deterministic_shortest_route_per_predecessor
award_paths = [path for path in paths["delivery"] if path["source_event_id"] == "award"]
assert [path["event_ids"] for path in award_paths] == [["award", "spec-a", "delivery"]]
+ assert award_paths[0]["edges"][0]["temporal_evidence"] == {
+ "truth_status_code": "observed",
+ "interval_relations": ["before"],
+ "artifact_digest_sha256": "a" * 64,
+ }
+ assert award_paths[0]["edges"][1]["temporal_evidence"] is None
diff --git a/tests/test_project_history_ingestion.py b/tests/test_project_history_ingestion.py
index c4886becf..c07a595b8 100644
--- a/tests/test_project_history_ingestion.py
+++ b/tests/test_project_history_ingestion.py
@@ -57,6 +57,13 @@ def test_project_history_query_binds_corporate_and_process_scopes() -> None:
assert result["events"][0]["event_type_code"] == "source_recorded"
assert result["events"][0]["occurred_at"] == "2025-12-20T00:00:00Z"
assert result["events"][0]["time_basis_code"] == "document_time"
+ edge_query, edge_args = next(
+ (query, args) for query, args in connection.calls if "from post_lineage_edge" in query
+ )
+ assert "project_journey_temporal_relation" in edge_query
+ assert "project_journey_temporal_relation_kind" in edge_query
+ assert "temporal_run.knowledge_cutoff <= $2" in edge_query
+ assert edge_args[1] == datetime(2026, 2, 1, tzinfo=timezone.utc)
def test_project_history_query_uses_the_same_ascii_edge_whitespace_as_python() -> None:
diff --git a/tests/test_temporal_journey_artifact.py b/tests/test_temporal_journey_artifact.py
new file mode 100644
index 000000000..3bd08779b
--- /dev/null
+++ b/tests/test_temporal_journey_artifact.py
@@ -0,0 +1,220 @@
+"""Typed temporal-artifact admission tests."""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+
+import pytest
+
+from backend.app.project_journey_temporal import (
+ TemporalArtifactAdmissionError,
+ persist_project_journey_temporal_artifact,
+)
+from lineageweave.temporal_journey_artifact import (
+ TemporalJourneyArtifactError,
+ parse_temporal_journey_artifact,
+)
+
+
+def _payload(*, run_id: str = "remote-1") -> bytes:
+ return json.dumps(
+ {
+ "schema_version": "tepp.tdt_chronos_interval_consistency.v1",
+ "run_id": run_id,
+ "snapshot_id": "snapshot-1",
+ "input_digest_sha256": "a" * 64,
+ "relations": [{
+ "left_event_id": "00000000-0000-0000-0000-000000000001",
+ "right_event_id": "00000000-0000-0000-0000-000000000002",
+ "allen_relations": ["before", "meets"],
+ "observed": False,
+ "support_assertion_ordinals": [0, 2],
+ }],
+ },
+ separators=(",", ":"),
+ ).encode()
+
+
+def _parse(payload: bytes):
+ return parse_temporal_journey_artifact(
+ payload,
+ expected_run_id="remote-1",
+ expected_snapshot_id="snapshot-1",
+ expected_input_digest_sha256="a" * 64,
+ expected_artifact_digest_sha256=hashlib.sha256(payload).hexdigest(),
+ )
+
+
+def test_parser_binds_canonical_bytes_and_all_identities() -> None:
+ """The admitted DTO retains no unbound provider field."""
+
+ result = _parse(_payload())
+ assert result.relations[0].allen_relations == ("before", "meets")
+ assert result.relations[0].support_assertion_ordinals == (0, 2)
+
+
+@pytest.mark.parametrize("mutation", ["digest", "run", "unknown", "order"])
+def test_parser_rejects_changed_or_noncanonical_artifacts(mutation: str) -> None:
+ """Malformed, moved, or noncanonical payloads fail closed."""
+
+ payload = _payload(run_id="other" if mutation == "run" else "remote-1")
+ if mutation == "unknown":
+ value = json.loads(payload)
+ value["extra"] = True
+ payload = json.dumps(value, separators=(",", ":")).encode()
+ if mutation == "order":
+ value = json.loads(payload)
+ value["relations"][0]["allen_relations"] = ["meets", "before"]
+ payload = json.dumps(value, separators=(",", ":")).encode()
+ digest = "b" * 64 if mutation == "digest" else hashlib.sha256(payload).hexdigest()
+ with pytest.raises(TemporalJourneyArtifactError):
+ parse_temporal_journey_artifact(
+ payload,
+ expected_run_id="remote-1",
+ expected_snapshot_id="snapshot-1",
+ expected_input_digest_sha256="a" * 64,
+ expected_artifact_digest_sha256=digest,
+ )
+
+
+@pytest.mark.parametrize(
+ ("payload", "input_digest", "artifact_digest"),
+ [
+ (b"", "a" * 64, "0" * 64),
+ (b"{}", "bad", hashlib.sha256(b"{}").hexdigest()),
+ (b"\xff", "a" * 64, hashlib.sha256(b"\xff").hexdigest()),
+ (b" {\"x\":1}", "a" * 64, hashlib.sha256(b" {\"x\":1}").hexdigest()),
+ (b"[]", "a" * 64, hashlib.sha256(b"[]").hexdigest()),
+ ],
+)
+def test_parser_rejects_size_digest_encoding_and_top_level_shape(
+ payload: bytes, input_digest: str, artifact_digest: str
+) -> None:
+ """Every outer wire boundary rejects before relation persistence."""
+
+ with pytest.raises(TemporalJourneyArtifactError):
+ parse_temporal_journey_artifact(
+ payload,
+ expected_run_id="remote-1",
+ expected_snapshot_id="snapshot-1",
+ expected_input_digest_sha256=input_digest,
+ expected_artifact_digest_sha256=artifact_digest,
+ )
+
+
+def test_parser_rejects_empty_and_malformed_relation_collections() -> None:
+ """An empty result or untyped relation is not journey evidence."""
+
+ for relations in ([], ["not-an-object"]):
+ value = json.loads(_payload())
+ value["relations"] = relations
+ payload = json.dumps(value, separators=(",", ":")).encode()
+ with pytest.raises(TemporalJourneyArtifactError):
+ _parse(payload)
+
+
+class _Connection:
+ """Capture the normalized producer statements."""
+
+ def __init__(
+ self,
+ remote_run_id: str = "remote-1",
+ existing_digest: str | None = None,
+ ) -> None:
+ self.remote_run_id = remote_run_id
+ self.existing_digest = existing_digest
+ self.execute_calls: list[tuple[str, tuple[object, ...]]] = []
+ self.many_calls: list[tuple[str, list[tuple[object, ...]]]] = []
+
+ async def fetchrow(self, query: str, *args: object):
+ """Return the terminal binding and no prior artifact."""
+
+ if "analysis_run_tepp_result" in query:
+ return {"remote_run_id": self.remote_run_id}
+ return (
+ {"artifact_digest_sha256": self.existing_digest}
+ if self.existing_digest is not None
+ else None
+ )
+
+ async def execute(self, query: str, *args: object):
+ """Capture artifact metadata persistence."""
+
+ self.execute_calls.append((query, args))
+
+ async def executemany(self, query: str, args: list[tuple[object, ...]]):
+ """Capture normalized relation children."""
+
+ self.many_calls.append((query, args))
+
+
+def test_producer_persists_relation_kinds_and_support_separately() -> None:
+ """One accepted artifact produces normalized, auditable rows."""
+
+ payload = _payload()
+ connection = _Connection()
+ asyncio.run(
+ persist_project_journey_temporal_artifact(
+ connection,
+ analysis_run_id="00000000-0000-0000-0000-000000000010",
+ payload=payload,
+ expected_run_id="remote-1",
+ expected_snapshot_id="snapshot-1",
+ expected_input_digest_sha256="a" * 64,
+ expected_artifact_digest_sha256=hashlib.sha256(payload).hexdigest(),
+ )
+ )
+ assert len(connection.execute_calls) == 1
+ assert [len(rows) for _query, rows in connection.many_calls] == [1, 2, 2]
+
+
+def test_producer_rejects_a_terminal_run_mismatch() -> None:
+ """A valid artifact cannot be attached to another persisted run."""
+
+ payload = _payload()
+ with pytest.raises(TemporalArtifactAdmissionError):
+ asyncio.run(
+ persist_project_journey_temporal_artifact(
+ _Connection("different"),
+ analysis_run_id="00000000-0000-0000-0000-000000000010",
+ payload=payload,
+ expected_run_id="remote-1",
+ expected_snapshot_id="snapshot-1",
+ expected_input_digest_sha256="a" * 64,
+ expected_artifact_digest_sha256=hashlib.sha256(payload).hexdigest(),
+ )
+ )
+
+
+def test_producer_is_idempotent_and_rejects_changed_artifact() -> None:
+ """A run may replay identical bytes but cannot change immutable evidence."""
+
+ payload = _payload()
+ digest = hashlib.sha256(payload).hexdigest()
+ same = _Connection(existing_digest=digest)
+ asyncio.run(
+ persist_project_journey_temporal_artifact(
+ same,
+ analysis_run_id="00000000-0000-0000-0000-000000000010",
+ payload=payload,
+ expected_run_id="remote-1",
+ expected_snapshot_id="snapshot-1",
+ expected_input_digest_sha256="a" * 64,
+ expected_artifact_digest_sha256=digest,
+ )
+ )
+ assert same.execute_calls == []
+ with pytest.raises(TemporalArtifactAdmissionError):
+ asyncio.run(
+ persist_project_journey_temporal_artifact(
+ _Connection(existing_digest="b" * 64),
+ analysis_run_id="00000000-0000-0000-0000-000000000010",
+ payload=payload,
+ expected_run_id="remote-1",
+ expected_snapshot_id="snapshot-1",
+ expected_input_digest_sha256="a" * 64,
+ expected_artifact_digest_sha256=digest,
+ )
+ )