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, + ) + )