Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions backend/app/project_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +156 to +159

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Newest temporal artifact selected by admitted_at

The lateral subquery in _EDGE_SQL ranks candidate artifacts by admitted_at desc then digest, taking one. When two runs cover the same edge, the most recently admitted wins even if another has a later analysis cutoff. Confirm this matches the intended "newest" semantics in ADR 0270.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

) temporal on true
Comment on lines +141 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Temporal evidence not bound to view snapshot

The lateral join matches temporal relations only by edge endpoints and knowledge_cutoff; it does not bind to the snapshot the view is built from. A temporal artifact computed on a different snapshot covering the same pair still attaches. ADR 0270 specifies only cutoff, so this appears intended.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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
Expand Down Expand Up @@ -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,
Expand All @@ -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
117 changes: 117 additions & 0 deletions backend/app/project_journey_temporal.py
Original file line number Diff line number Diff line change
@@ -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,
)
Comment on lines +67 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: FOR UPDATE lock depends on caller's transaction

persist_project_journey_temporal_artifact runs select ... for update then inserts without opening its own transaction, relying on the caller supplying a transaction-scoped connection. An autocommit connection would drop the lock immediately; concurrent admissions then race, though unique constraints still fail the loser closed. No production caller exists in this PR.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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
61 changes: 61 additions & 0 deletions docs/adr/0270-digest-bound-project-journey-temporal-evidence.md
Original file line number Diff line number Diff line change
@@ -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/
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
3 changes: 3 additions & 0 deletions docs/product-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/components/ProjectHistoryTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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");
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/ProjectHistoryTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,11 @@ export function ProjectHistoryTimeline({
<span className="project-history-truth">
{projectHistoryText(locale, "inferred")}
</span>
{path.edges.some((edge) => edge.temporal_evidence != null) ? (
<span className="project-history-truth">
{projectHistoryText(locale, "timeOrderChecked")}
</span>
) : null}
</li>
))}
</ul>
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/projectHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -113,6 +118,7 @@ const MESSAGE_KEYS = [
"priorHistory",
"noPriorHistory",
"inferredBoundary",
"timeOrderChecked",
"projectEvidence",
"sourceRecordEvidence",
"supportingRecordEvidence",
Expand Down Expand Up @@ -164,6 +170,7 @@ const EN: Record<ProjectHistoryMessageKey, string> = {
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",
Expand Down Expand Up @@ -212,6 +219,7 @@ const MESSAGES: Record<Locale, Record<ProjectHistoryMessageKey, string>> = {
priorHistory: "관련 과거 이력",
noPriorHistory: "이 이벤트로 이어지는 공개 가능한 이전 계보가 없습니다.",
inferredBoundary: "이는 추론된 관련 이력이며 인과관계나 권위 있는 인사 배정 기록이 아닙니다.",
timeOrderChecked: "시간 순서를 확인했습니다. 위 기록을 열어 근거 날짜를 비교하세요.",
projectEvidence: "프로젝트 식별 근거",
sourceRecordEvidence: "원천 기록",
supportingRecordEvidence: "뒷받침 기록",
Expand Down Expand Up @@ -257,6 +265,7 @@ const MESSAGES: Record<Locale, Record<ProjectHistoryMessageKey, string>> = {
priorHistory: "相关既往历史",
noPriorHistory: "此事件没有可见的既往谱系路径。",
inferredBoundary: "这是推断的相关历史,并非因果关系或权威任命记录。",
timeOrderChecked: "时间顺序已核验。请打开上方记录比较依据日期。",
projectEvidence: "项目身份依据",
sourceRecordEvidence: "来源记录",
supportingRecordEvidence: "支持记录",
Expand Down Expand Up @@ -302,6 +311,7 @@ const MESSAGES: Record<Locale, Record<ProjectHistoryMessageKey, string>> = {
priorHistory: "関連する過去履歴",
noPriorHistory: "このイベントに至る可視の過去系譜はありません。",
inferredBoundary: "これは推論された関連履歴であり、因果関係や権威ある配属記録ではありません。",
timeOrderChecked: "時間順序を確認しました。上の記録を開いて根拠の日付を比較してください。",
projectEvidence: "プロジェクト識別根拠",
sourceRecordEvidence: "元レコード",
supportingRecordEvidence: "根拠レコード",
Expand Down Expand Up @@ -347,6 +357,7 @@ const MESSAGES: Record<Locale, Record<ProjectHistoryMessageKey, string>> = {
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ợ",
Expand Down
Loading