From 39934c51a979fd32af3fc496b3fecce200833b24 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:19:56 +0000 Subject: [PATCH 1/7] feat: add normalized analysis-run registry (v0.72.0) Add migration 0012 as an additive Milestone 2.1 persistence boundary on protected main: immutable source snapshots, aggregate counts, account-scoped runs, product scope, append-only status events, and a current-status view. Wire TEPP AnalysisRunRequest to snapshot_id and knowledge_cutoff with a fail-closed transport, keep new orchestrator helpers on mode=auto, and record ADR 0014 plus beginner ERD and APA 7th traceability. Refs #87 #79 Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 11 +- CHANGELOG.d/0.72.0-analysis-run-registry.md | 11 + CHANGELOG.md | 13 + README.md | 3 +- docker-compose.yml | 5 +- docker/postgres-init/Dockerfile | 1 + .../0014-normalized-analysis-run-registry.md | 216 ++++ docs/analysis-run-registry.md | 108 ++ .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 99 ++ frontend/package.json | 2 +- lineageweave/__init__.py | 2 +- lineageweave/analysis_run_orchestration.py | 105 ++ lineageweave/tepp_client.py | 88 +- migrations/0012_analysis_run_registry.sql | 569 +++++++++ .../rollback/0012_analysis_run_registry.sql | 70 ++ pyproject.toml | 2 +- tests/test_analysis_run_orchestration.py | 142 +++ tests/test_analysis_run_registry_schema.py | 1075 +++++++++++++++++ tests/test_documentation_hygiene.py | 58 + tests/test_public_content_boundary.py | 100 ++ tests/test_tepp_client.py | 101 +- uv.lock | 2 +- 22 files changed, 2772 insertions(+), 11 deletions(-) create mode 100644 CHANGELOG.d/0.72.0-analysis-run-registry.md create mode 100644 docs/adr/0014-normalized-analysis-run-registry.md create mode 100644 docs/analysis-run-registry.md create mode 100644 docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md create mode 100644 lineageweave/analysis_run_orchestration.py create mode 100644 migrations/0012_analysis_run_registry.sql create mode 100644 migrations/rollback/0012_analysis_run_registry.sql create mode 100644 tests/test_analysis_run_orchestration.py create mode 100644 tests/test_analysis_run_registry_schema.py create mode 100644 tests/test_documentation_hygiene.py create mode 100644 tests/test_public_content_boundary.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 617b8b95d..e62118d3e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -140,6 +140,13 @@ identities and content) and `migrations/0001_initial_schema.sql` for the (skipped without a reachable PostgreSQL server, same pattern as the real-provider LLM tests). +Milestone 2.1 adds an additive analysis-run registry in +`migrations/0012_analysis_run_registry.sql` (ADR 0014): immutable source +snapshots, aggregate counts, account-scoped runs, one product scope, and +append-only status events with a derived current-status view. Beginner +ERD: [`docs/analysis-run-registry.md`](docs/analysis-run-registry.md). +This slice has no public CRUD API. + ### Local infrastructure (Docker Compose) `docker-compose.yml` runs PostgreSQL, Valkey, and a real Keycloak OIDC @@ -157,8 +164,8 @@ makes them reproducible in CI. Valkey is the Phase 2+ event queue (not a traditional MQ) for asynchronous work like Keyman/Knowledge-Graph recomputation once posts change. Postgres's app database is auto-migrated on first boot from the same `migrations/0001_initial_schema.sql` file -`tests/test_schema.py` applies -- one schema file, no drift between what's -tested and what ships. +`tests/test_schema.py` applies, plus later `0002`–`0012` upgrades -- one +schema chain, no drift between what's tested and what ships. ### Backend (`backend/`) diff --git a/CHANGELOG.d/0.72.0-analysis-run-registry.md b/CHANGELOG.d/0.72.0-analysis-run-registry.md new file mode 100644 index 000000000..0fd67f263 --- /dev/null +++ b/CHANGELOG.d/0.72.0-analysis-run-registry.md @@ -0,0 +1,11 @@ +# 0.72.0 — Normalized analysis-run registry + +## Added + +- Migration `0012_analysis_run_registry.sql` adds the Milestone 2.1 registry: + immutable source snapshots, non-negative aggregate counts, account-scoped + analysis runs, one authorization scope per run, append-only status events, + and a derived current-status view. +- Fail-closed TEPP helpers bind `AnalysisRunRequest` to `snapshot_id` and + `knowledge_cutoff` without forking TEPP arithmetic. +- New contextual-orchestrator helpers request `mode="auto"` only. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0096828a2..8a29c7ab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.72.0] - 2026-08-16 + +### Added + +- Additive analysis-run registry (`migrations/0012_analysis_run_registry.sql`, + ADR 0014). Operators can persist an immutable source snapshot, aggregate + reconciliation counts, an authenticated Demo Corp requester, run-owned + knowledge cutoff, product scope, and append-only legal status events. + Current status is a view. There is no public CRUD API in this slice. + TEPP stays fail-closed unless an HTTPS `POST /v1/analysis-runs` or + in-process `tepp_api` is injected. New orchestrator helpers use + `mode="auto"` only. + ## [0.71.0] - 2026-08-14 ### Added diff --git a/README.md b/README.md index 2f963c7f0..90c62cf77 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,8 @@ Postgres/Redis/local server on those. Override via `.env` (copy Postgres's `POSTGRES_DB` (the "app" database) is migrated automatically on first boot -- `docker/postgres-init/Dockerfile` bakes in the exact same `migrations/0001_initial_schema.sql` file `tests/test_schema.py` applies, -no re-typed copy. +no re-typed copy, then the later `0002`–`0012` upgrade files including +the analysis-run registry. `backend/` is a FastAPI app talking directly to that database (`asyncpg`, no ORM, no file DB) and to Keycloak's live JWKS for OIDC verification: diff --git a/docker-compose.yml b/docker-compose.yml index 5087366b4..fd144f263 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,9 @@ services: # product schema migration ship inside the image itself -- portable # across hosts/CI runners that don't share a filesystem with the # Docker daemon. Context is the repo root so the Dockerfile can COPY - # migrations/0001_initial_schema.sql (single source of truth -- - # tests/test_schema.py applies this exact same file). + # migrations/0001_initial_schema.sql plus later 0002-0012 upgrades + # (single source of truth -- tests/test_schema.py applies 0001; + # the registry contract applies 0001-0012). build: context: . dockerfile: docker/postgres-init/Dockerfile diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index 0c6323a92..54e2cf221 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -18,6 +18,7 @@ COPY migrations/0008_post_summary_result.sql /docker-entrypoint-initdb.d/09-post COPY migrations/0009_shared_metric_bank.sql /docker-entrypoint-initdb.d/10-shared-metric-bank.sql COPY migrations/0010_report_item_information.sql /docker-entrypoint-initdb.d/11-report-item-information.sql COPY migrations/0011_post_chat_result.sql /docker-entrypoint-initdb.d/12-post-chat-result.sql +COPY migrations/0012_analysis_run_registry.sql /docker-entrypoint-initdb.d/13-analysis-run-registry.sql # Official image already drops to this account at runtime; declare it so # the Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER postgres diff --git a/docs/adr/0014-normalized-analysis-run-registry.md b/docs/adr/0014-normalized-analysis-run-registry.md new file mode 100644 index 000000000..c414c5dbb --- /dev/null +++ b/docs/adr/0014-normalized-analysis-run-registry.md @@ -0,0 +1,216 @@ +# ADR 0014 — Milestone 2.1 uses a normalized, additive analysis-run registry + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-16 +**Depends on:** ADR 0001 demo identity/data boundary and ADR 0013 adaptive orchestrator default + +## Context + +Protected `main` has migrations `0001`–`0011` and package version `0.71.0`. +ADR 0013 is already taken by the merged adaptive-orchestrator default. Milestone 2 +must persist real analysis-run identity without merging the retained parallel +application, without a second React app, and without a production Keyverse bind. + +The product needs a small durable root that answers: + +- which immutable capture was used; +- which evidence was available by the run's knowledge cutoff; +- which authenticated account requested the work; +- which product scope and reproducibility digests governed the run; +- which aggregate counts reconcile the capture; +- which legal lifecycle transitions occurred. + +The registry does not store source SQL, DSNs, raw posts, inline images, provider +payloads, credentials, raw exceptions, or another service's application rows. + +## Decision + +Migration `0012_analysis_run_registry.sql` introduces five normalized relations +and one read projection. + +```mermaid +erDiagram + ANALYSIS_SOURCE_SNAPSHOT ||--o{ ANALYSIS_SOURCE_COUNT : reconciles + ANALYSIS_SOURCE_SNAPSHOT ||--o{ ANALYSIS_RUN : anchors + USER_ACCOUNT ||--o{ ANALYSIS_RUN : requests + ANALYSIS_RUN ||--o| ANALYSIS_RUN_SCOPE : limits + CORPORATE_ENTITY |o--o{ ANALYSIS_RUN_SCOPE : scopes + PROCESS_UNIT |o--o{ ANALYSIS_RUN_SCOPE : scopes + ANALYSIS_RUN ||--o{ ANALYSIS_RUN_STATUS_EVENT : records + + ANALYSIS_SOURCE_SNAPSHOT { + uuid analysis_source_snapshot_id PK + text snapshot_sha256 UK + text source_contract_version + timestamptz maximum_available_time + timestamptz captured_at + } + ANALYSIS_SOURCE_COUNT { + uuid analysis_source_snapshot_id PK,FK + text count_type_code PK,FK + bigint count_value + } + ANALYSIS_RUN { + uuid analysis_run_id PK + uuid analysis_source_snapshot_id FK + uuid requested_by_account_id FK + text idempotency_key UK + timestamptz knowledge_cutoff + text configuration_sha256 + text model_contract_sha256 + text prompt_bundle_sha256 + text code_revision_sha + } + ANALYSIS_RUN_SCOPE { + uuid analysis_run_id PK,FK + text scope_kind_code FK + uuid corporate_entity_id FK + uuid process_unit_id FK + text scope_key + } + ANALYSIS_RUN_STATUS_EVENT { + uuid analysis_run_id PK,FK + int status_ordinal PK + text status_code FK + timestamptz occurred_at + timestamptz recorded_at + text failure_code + boolean retryable + } +``` + +`analysis_run_current_status` is a `VIEW` over the latest status event. It is +not a second mutable table. + +### Temporal ownership + +`analysis_source_snapshot.maximum_available_time` is an evidence fact: the +latest time at which any admitted fact became available. `analysis_run.knowledge_cutoff` +is an analysis fact: the latest information that this particular run may use. +A reusable capture therefore does **not** own one knowledge cutoff. + +Run creation locks the snapshot and requires: + +```text +maximum_available_time <= knowledge_cutoff <= requested_at +captured_at <= requested_at +``` + +This aggregate guard complements TEPP's finer event, assertion, document, +system, availability, and cutoff clocks. It does not replace TEPP temporal or +psychometric computation. + +### Identity and idempotency + +Every run references a real `user_account`. `requested_by_account_id` is not +nullable. Idempotency keys are trimmed, control-free canonical values and are +unique per authenticated account rather than globally. + +### Immutability and concurrency + +Snapshot identity and availability reject updates. Aggregate count values reject +updates. Count insert/delete and first run creation acquire the same snapshot-row +lock before checking whether a run exists. After the first run, the complete +count set is frozen. The analysis request and its authorization scope reject +updates and deletes. + +### Lifecycle state machine + +The parent run row serializes status appends. Events require contiguous +ordinals, monotonic occurrence time, and these transitions: + +```text +pending -> running | cancelled +running -> succeeded | failed | cancelled +succeeded | failed | cancelled -> no successor +``` + +The first event must be `pending`, requires an immutable scope, and cannot predate +the run request. Failed events require a lowercase machine-code identifier. + +### Authorization scope + +`analysis_run_scope` stores one immutable all-visible, corporate-entity, +process-unit, or thread-group scope. This slice adds no public CRUD API. + +### Service boundaries + +- **LineageWeave** owns product run identity, authorized scope, lifecycle, + aggregate reconciliation, and product-visible derivation references. +- **TEPP** owns exact evidence spans and psychometric measurement through a + versioned import or REST contract. `AnalysisRunRequest` is wired to + `snapshot_id` and `knowledge_cutoff`. Transport stays fail-closed unless a + real HTTPS `POST /v1/analysis-runs` or in-process `tepp_api` is injected. +- **contextual-orchestrator** owns provider-neutral model routing. New helpers + in this slice use `mode="auto"` only and fail closed on a missing base URL, + `invalid_mode`, or non-2xx response. This repo does not invent a portable + task envelope. +- **Keyverse** owns identity. This slice adds no local IdP and no production + Keyverse bind. + +No component reads another service's private application tables. + +## Alternatives considered + +### Merge the parallel experiment unchanged + +Rejected. It replaces reviewed product history and creates a second database +authority. + +### Reuse stacked migration 0018 or ADR 0013 + +Rejected. Protected main ends at migration 0011. ADR 0013 is already the +adaptive-orchestrator default. This slice is `0012` and ADR 0014. + +### Store one JSON document per run + +Rejected. Relational identity, scope, counts, clocks, and lifecycle need +independent constraints. + +### Store knowledge cutoff on the snapshot + +Rejected. One immutable capture can support multiple analysis requests with +different historical cutoffs. + +## Security, privacy, and compliance consequences + +- Necessary PII remains in its authorized source/product tables. +- This registry stores opaque UUIDs, digests, bounded machine codes, aggregate + counts, and clocks only. +- Public Git content may mention synthetic Demo Corp and aggregate ranges only. +- The design supports SOC 2 and CSAP evidence collection; it does not claim + certification. + +## Failure and rollback + +Migration replay is idempotent and rejects lookup-category collisions. The +rollback refuses to remove non-empty registry relations. An empty rollback +removes the view, tables, functions, and lookup rows and is itself replayable. + +## Follow-up sequence + +1. Add a transaction repository that creates snapshot, counts, run, scope, and + first status atomically. +2. Add RBAC/ABAC-protected read projections after hidden-evidence tests pass. +3. Add a normalized PostgreSQL outbox and Valkey delivery worker. +4. Consume TEPP measurement and deeper orchestrator workflows only through + reviewed versioned boundaries. +5. Execute private actual-data analysis outside public source control. + +## References — APA 7th + +International Organization for Standardization. (2019). *ISO 8601-1:2019: Date +and time—Representations for information interchange—Part 1: Basic rules* +(confirmed 2024; Amendment 1:2022). + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. +World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: +5.5. Constraints*. https://www.postgresql.org/docs/current/ddl-constraints.html + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C +Recommendation). https://www.w3.org/TR/prov-o/ + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). +https://www.w3.org/TR/owl-time/ diff --git a/docs/analysis-run-registry.md b/docs/analysis-run-registry.md new file mode 100644 index 000000000..5bb3b5e15 --- /dev/null +++ b/docs/analysis-run-registry.md @@ -0,0 +1,108 @@ +# Analysis-run registry (beginner guide) + +This page explains the Milestone 2.1 registry in plain language. The durable +objects live in `migrations/0012_analysis_run_registry.sql`. See +[ADR 0014](adr/0014-normalized-analysis-run-registry.md) for the decision +record and [the APA 7th traceability note](doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md) +for the standards mapping. + +There is no public create/read/update/delete API in this slice. The registry +is a database contract only. Examples use synthetic **Demo Corp** and +aggregate ranges, never private source names or exact private counts. + +## What a buyer can rely on + +An analysis run is a dated, account-owned request to derive product evidence +from one frozen source capture. The database remembers: + +1. **What was captured** — a digest and the latest time any admitted fact + could be known (`maximum_available_time`). +2. **How large the capture was** — non-negative aggregate counts (for + example, documents in a low tens range after `make seed`), not raw rows. +3. **Who asked, and with which cutoff** — a real `user_account`, an + account-scoped idempotency key, and a run-owned `knowledge_cutoff`. +4. **How wide the request was** — all visible records, one Demo Corp + corporate entity, one process unit, or one thread group. +5. **What happened next** — append-only status events. Current status is a + view, not a second editable table. + +## Entity-relationship diagram + +```mermaid +erDiagram + ANALYSIS_SOURCE_SNAPSHOT ||--o{ ANALYSIS_SOURCE_COUNT : reconciles + ANALYSIS_SOURCE_SNAPSHOT ||--o{ ANALYSIS_RUN : anchors + USER_ACCOUNT ||--o{ ANALYSIS_RUN : requests + ANALYSIS_RUN ||--o| ANALYSIS_RUN_SCOPE : limits + CORPORATE_ENTITY |o--o{ ANALYSIS_RUN_SCOPE : scopes + PROCESS_UNIT |o--o{ ANALYSIS_RUN_SCOPE : scopes + ANALYSIS_RUN ||--o{ ANALYSIS_RUN_STATUS_EVENT : records + + ANALYSIS_SOURCE_SNAPSHOT { + uuid analysis_source_snapshot_id PK + text snapshot_sha256 UK + text source_contract_version + timestamptz maximum_available_time + timestamptz captured_at + } + ANALYSIS_SOURCE_COUNT { + uuid analysis_source_snapshot_id PK,FK + text count_type_code PK,FK + bigint count_value + } + ANALYSIS_RUN { + uuid analysis_run_id PK + uuid analysis_source_snapshot_id FK + uuid requested_by_account_id FK + text idempotency_key + timestamptz knowledge_cutoff + text configuration_sha256 + } + ANALYSIS_RUN_SCOPE { + uuid analysis_run_id PK,FK + text scope_kind_code + uuid corporate_entity_id FK + uuid process_unit_id FK + text scope_key + } + ANALYSIS_RUN_STATUS_EVENT { + uuid analysis_run_id PK,FK + int status_ordinal PK + text status_code + timestamptz occurred_at + timestamptz recorded_at + } +``` + +`analysis_run_current_status` is a SQL view: the latest event per run. + +## Leakage guard + +The registry enforces one aggregate clock rule: + +```text +maximum_available_time <= knowledge_cutoff <= requested_at +captured_at <= requested_at +``` + +This complements TEPP's six finer clocks. It does not replace them and does +not copy TEPP arithmetic. + +## Legal status path + +```text +pending -> running | cancelled +running -> succeeded | failed | cancelled +terminal -> no successor +``` + +## What stays out of public Git + +- source-export table names +- industrial-group or source-organization names +- raw row identifiers +- image or base64 bytes +- credentials +- exact private counts + +Synthetic Demo Corp and aggregate ranges are the public vocabulary. diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md new file mode 100644 index 000000000..64d68d6f6 --- /dev/null +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -0,0 +1,99 @@ +# Analysis-run registry standards and research traceability + +**Status:** Active PR evidence; not protected-main truth until merge. +**Scope:** Migration 0012, ADR 0014, rollback, and real-PostgreSQL contract tests. + +## Standards mapped to implementation + +| Source | Product implication | Implemented evidence | +|---|---|---| +| W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, and immutable digests. | +| W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. | +| ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | +| PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. | +| NIST SP 800-92 | Treat audit records as bounded, protected operational evidence rather than unstructured application logging. | Append-only status events, machine failure codes, actor identity, occurrence/record clocks, fail-closed rollback, and exclusion of raw source/provider payloads. | +| OpenAPI 3.2.0 | Define explicit versioned API schemas rather than exposing database rows. | API intentionally deferred; ADR 0014 requires authorization tests before a product surface is claimed. | + +## Temporal reasoning + +The registry applies a bitemporal discipline without claiming a complete +general-purpose bitemporal database: + +- `maximum_available_time` answers when the newest admitted evidence became + knowable; +- `captured_at` answers when the immutable source snapshot was materialized; +- `knowledge_cutoff` answers what a specific analysis was allowed to know; +- `requested_at` answers when that analysis was requested; +- `occurred_at` and `recorded_at` distinguish lifecycle occurrence from durable + database recording. + +The database requires the aggregate leakage boundary: + +```text +maximum_available_time <= knowledge_cutoff <= requested_at +captured_at <= requested_at +``` + +TEPP remains the authority for finer event/assertion/document/system/available +clocks and temporal psychometrics. The registry does not duplicate TEPP +measurement outputs. + +## Audit and privacy boundary + +The registry may store: + +- opaque product UUIDs; +- authenticated account UUIDs; +- SHA-256 digests; +- bounded configuration/version identifiers; +- aggregate counts; +- bounded status/failure codes; +- timezone-aware clocks. + +The registry must not store: + +- source SQL or source-table names; +- DSNs, credentials, or provider secrets; +- raw posts, HTML, images, base64 data, or attachments; +- model prompts/responses or raw exceptions; +- another service's application tables; +- organization-specific source identifiers in public fixtures or documentation. + +Public documentation may mention synthetic Demo Corp and aggregate ranges only. + +## Verification matrix + +| Claim | Falsifiable test | +|---|---| +| One snapshot supports multiple analyses | Insert two runs over one snapshot with different valid cutoffs. | +| Future evidence is excluded | Reject a run whose cutoff precedes the snapshot's maximum availability time. | +| Evidence cannot change after derivation | Reject snapshot/count updates and count insert/delete after the first run. | +| Request identity is stable | Reject analysis-run updates; scope and lifecycle live in their own relations. | +| Idempotency is actor-scoped | Permit identical opaque keys for two accounts and reject reuse by the same account, including concurrent racers. | +| Lifecycle is ordered | Require pending first, contiguous ordinals, monotonic time, legal transitions, terminal finality, and append-only rows. | +| Rollback does not erase audit data silently | Reject rollback with any registry rows and allow replay after explicit cleanup. | + +## APA 7th references + +International Organization for Standardization. (2019). *ISO 8601-1:2019: Date +and time—Representations for information interchange—Part 1: Basic rules* +(confirmed 2024; Amendment 1:2022). + +Kent, K., & Souppaya, M. (2006). *Guide to computer security log management* +(NIST Special Publication 800-92). National Institute of Standards and +Technology. https://doi.org/10.6028/NIST.SP.800-92 + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. +World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ + +OpenAPI Initiative. (2025). *OpenAPI specification, version 3.2.0*. +https://spec.openapis.org/oas/v3.2.0.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: +5.5. Constraints*. https://www.postgresql.org/docs/current/ddl-constraints.html + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology* (W3C +Recommendation). https://www.w3.org/TR/prov-o/ + +World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). +https://www.w3.org/TR/owl-time/ diff --git a/frontend/package.json b/frontend/package.json index 9c84795d9..0f1fa4ce8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.71.0", + "version": "0.72.0", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 0ac8e50fe..07f5c5c58 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -35,4 +35,4 @@ "sentence_excerpts", ] -__version__ = "0.71.0" +__version__ = "0.72.0" diff --git a/lineageweave/analysis_run_orchestration.py b/lineageweave/analysis_run_orchestration.py new file mode 100644 index 000000000..705d6fde1 --- /dev/null +++ b/lineageweave/analysis_run_orchestration.py @@ -0,0 +1,105 @@ +"""Fail-closed contextual-orchestrator helper for analysis-run work. + +New helpers in this slice always request ``mode="auto"``. They do not add +``mode="verify"`` calls and do not invent a portable task envelope. Missing +base URL, ``invalid_mode``, and non-2xx responses fail closed. +""" + +from __future__ import annotations + +from typing import Any, Callable + +from .http_client import HttpClientError, post_json + +Poster = Callable[..., dict[str, Any]] + + +class OrchestratorNotAvailable(RuntimeError): + """Raised when the orchestrator base URL is missing or the call fails closed.""" + + +class NullAnalysisRunOrchestrationClient: + """No orchestrator configured -- analysis-run completion is unavailable.""" + + available = False + + def complete(self, prompt: str) -> str: + """Refuse to invent a completion when the channel is missing.""" + + raise RuntimeError( + "NullAnalysisRunOrchestrationClient cannot complete; check .available first" + ) + + +def request_auto_completion( + base_url: str | None, + prompt: str, + *, + api_key: str = "", + timeout: float = 60.0, + poster: Poster = post_json, +) -> str: + """POST ``/v1/chat/completions`` with ``mode="auto"`` and return the text. + + Raises: + OrchestratorNotAvailable: the base URL is missing, the orchestrator + reports ``invalid_mode``, the HTTP status is not 2xx, or the + response has no usable content. + """ + + if base_url is None or not str(base_url).strip(): + raise OrchestratorNotAvailable("missing orchestrator base URL") + headers = {"authorization": f"Bearer {api_key}"} if api_key else {} + try: + body = poster( + f"{str(base_url).rstrip('/')}/v1/chat/completions", + { + "messages": [{"role": "user", "content": prompt}], + "mode": "auto", + }, + headers=headers, + timeout=timeout, + ) + except HttpClientError as exc: + raise OrchestratorNotAvailable(f"orchestrator request failed: {exc}") from exc + if body.get("error") == "invalid_mode" or body.get("code") == "invalid_mode": + raise OrchestratorNotAvailable("invalid_mode") + try: + content = body["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise OrchestratorNotAvailable("orchestrator response missing content") from exc + if not isinstance(content, str) or not content.strip(): + raise OrchestratorNotAvailable("orchestrator response missing content") + return content + + +class ContextualOrchestratorAnalysisRunClient: + """Calls ``POST {base_url}/v1/chat/completions`` with ``mode="auto"``.""" + + available = True + + def __init__( + self, + base_url: str | None, + api_key: str = "", + *, + timeout: float = 60.0, + poster: Poster = post_json, + ) -> None: + if base_url is None or not str(base_url).strip(): + raise OrchestratorNotAvailable("missing orchestrator base URL") + self._base_url = str(base_url).rstrip("/") + self._api_key = api_key + self._timeout = timeout + self._poster = poster + + def complete(self, prompt: str) -> str: + """Return one auto-mode completion for ``prompt``.""" + + return request_auto_completion( + self._base_url, + prompt, + api_key=self._api_key, + timeout=self._timeout, + poster=self._poster, + ) diff --git a/lineageweave/tepp_client.py b/lineageweave/tepp_client.py index b9086141d..8b50501a3 100644 --- a/lineageweave/tepp_client.py +++ b/lineageweave/tepp_client.py @@ -20,10 +20,15 @@ from dataclasses import dataclass from typing import Any, Callable +from urllib.parse import urlparse + +from .http_client import HttpClientError, post_json + +Poster = Callable[..., dict[str, Any]] class TeppNotAvailable(RuntimeError): - """Raised by the default transport: TEPP has no live REST API yet.""" + """Raised when TEPP transport is missing, non-HTTPS, or fail-closed.""" def _no_transport(request: dict[str, Any]) -> dict[str, Any]: @@ -79,3 +84,84 @@ def __init__(self, transport: Callable[[dict[str, Any]], dict[str, Any]] = _no_t def submit_analysis_run(self, request: AnalysisRunRequest) -> dict[str, Any]: """Submit a request; returns TEPP's ``AnalysisRunAccepted`` envelope.""" return self._transport(request.to_json()) + + +def analysis_run_request_from_registry( + *, + snapshot_id: str, + knowledge_cutoff: str, + idempotency_key: str, + tenant_workspace_id: str, + model_contract_version: str, + output_profile: str, + contract_version: int = 1, +) -> AnalysisRunRequest: + """Build TEPP's published request from registry snapshot and cutoff clocks. + + ``snapshot_id`` and ``knowledge_cutoff`` are required registry facts. This + helper does not invent TEPP arithmetic, read TEPP tables, or add fields + beyond the published seven-property wire contract. + """ + + trimmed_snapshot = snapshot_id.strip() + trimmed_cutoff = knowledge_cutoff.strip() + if not trimmed_snapshot: + raise ValueError("snapshot_id is required") + if not trimmed_cutoff: + raise ValueError("knowledge_cutoff is required") + return AnalysisRunRequest( + idempotency_key=idempotency_key, + tenant_workspace_id=tenant_workspace_id, + snapshot_id=trimmed_snapshot, + knowledge_cutoff=trimmed_cutoff, + model_contract_version=model_contract_version, + output_profile=output_profile, + contract_version=contract_version, + ) + + +def create_https_analysis_run_transport( + base_url: str, + *, + api_key: str | None = None, + poster: Poster = post_json, + timeout: float = 60.0, +) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Return a fail-closed HTTPS POST ``/v1/analysis-runs`` transport. + + The default :class:`TeppClient` transport stays :class:`TeppNotAvailable`. + Callers inject this factory only when a real HTTPS endpoint exists. + """ + + parsed = urlparse(base_url) + if parsed.scheme != "https" or not parsed.hostname: + raise TeppNotAvailable( + "TEPP HTTP transport requires an https:// base URL for POST /v1/analysis-runs" + ) + endpoint = f"{base_url.rstrip('/')}/v1/analysis-runs" + headers = {"authorization": f"Bearer {api_key}"} if api_key else {} + + def transport(payload: dict[str, Any]) -> dict[str, Any]: + try: + return poster(endpoint, payload, headers=headers, timeout=timeout) + except HttpClientError as exc: + raise TeppNotAvailable(f"TEPP HTTPS POST failed: {exc}") from exc + + return transport + + +def create_in_process_tepp_transport( + tepp_api: Callable[[dict[str, Any]], dict[str, Any]], +) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Wrap an injected in-process ``tepp_api`` callable as a TeppClient transport. + + LineageWeave never imports or queries another service's tables. The + callable must already be a versioned TEPP API, not a local reimplementation + of TEPP arithmetic. + """ + + def transport(payload: dict[str, Any]) -> dict[str, Any]: + return tepp_api(payload) + + return transport + diff --git a/migrations/0012_analysis_run_registry.sql b/migrations/0012_analysis_run_registry.sql new file mode 100644 index 000000000..221647896 --- /dev/null +++ b/migrations/0012_analysis_run_registry.sql @@ -0,0 +1,569 @@ +-- Milestone 2.1 additive runtime bridge: normalized analysis-run registry. +-- +-- This migration records reproducibility, authorization scope, aggregate +-- reconciliation, and lifecycle evidence without storing source SQL, DSNs, +-- raw records, image bytes, provider payloads, credentials, or free-form JSON. +-- Snapshot availability is evidence-owned; the knowledge cutoff is run-owned, +-- so one immutable capture can support multiple historically valid analyses. + +begin; + +insert into common_lookup_value + (lookup_category, lookup_code, lookup_label, display_order) +values + ('analysis_run_kind', 'analysis_run_lineage', 'Lineage reconstruction', 0), + ('analysis_run_kind', 'analysis_run_report', 'Period report', 1), + ('analysis_run_kind', 'analysis_run_tepp', 'TEPP measurement', 2), + ('analysis_run_status', 'analysis_status_pending', 'Pending', 0), + ('analysis_run_status', 'analysis_status_running', 'Running', 1), + ('analysis_run_status', 'analysis_status_succeeded', 'Succeeded', 2), + ('analysis_run_status', 'analysis_status_failed', 'Failed', 3), + ('analysis_run_status', 'analysis_status_cancelled', 'Cancelled', 4), + ('analysis_run_scope', 'analysis_scope_all_visible', 'All authorized records', 0), + ('analysis_run_scope', 'analysis_scope_corporate_entity', 'Corporate entity', 1), + ('analysis_run_scope', 'analysis_scope_process_unit', 'Process unit', 2), + ('analysis_run_scope', 'analysis_scope_thread_group', 'Thread group', 3), + ('analysis_source_count', 'analysis_count_source_row', 'Source rows', 0), + ('analysis_source_count', 'analysis_count_document', 'Documents', 1), + ('analysis_source_count', 'analysis_count_thread', 'Threads', 2), + ('analysis_source_count', 'analysis_count_lineage_node', 'Lineage nodes', 3), + ('analysis_source_count', 'analysis_count_lineage_edge', 'Lineage edges', 4) +on conflict (lookup_code) do nothing; + +-- common_lookup_value deliberately makes lookup_code globally unique. A code +-- that already exists under another category is a migration conflict rather +-- than permission to attach the wrong vocabulary to an analysis column. +do $$ +declare + lookup_mismatch_count integer; +begin + select count(*) + into lookup_mismatch_count + from common_lookup_value as actual + join (values + ('analysis_run_lineage', 'analysis_run_kind'), + ('analysis_run_report', 'analysis_run_kind'), + ('analysis_run_tepp', 'analysis_run_kind'), + ('analysis_status_pending', 'analysis_run_status'), + ('analysis_status_running', 'analysis_run_status'), + ('analysis_status_succeeded', 'analysis_run_status'), + ('analysis_status_failed', 'analysis_run_status'), + ('analysis_status_cancelled', 'analysis_run_status'), + ('analysis_scope_all_visible', 'analysis_run_scope'), + ('analysis_scope_corporate_entity', 'analysis_run_scope'), + ('analysis_scope_process_unit', 'analysis_run_scope'), + ('analysis_scope_thread_group', 'analysis_run_scope'), + ('analysis_count_source_row', 'analysis_source_count'), + ('analysis_count_document', 'analysis_source_count'), + ('analysis_count_thread', 'analysis_source_count'), + ('analysis_count_lineage_node', 'analysis_source_count'), + ('analysis_count_lineage_edge', 'analysis_source_count') + ) as expected(lookup_code, lookup_category) + on expected.lookup_code = actual.lookup_code + where actual.lookup_category <> expected.lookup_category; + + if lookup_mismatch_count <> 0 then + raise exception 'analysis_run_registry_lookup_conflict'; + end if; +end +$$; + +create table if not exists analysis_source_snapshot ( + analysis_source_snapshot_id uuid primary key default uuid_generate_v4(), + snapshot_sha256 text not null unique, + source_contract_version text not null, + maximum_available_time timestamptz not null, + captured_at timestamptz not null, + created_at timestamptz not null default now(), + constraint analysis_source_snapshot_digest_check + check (snapshot_sha256 ~ '^[0-9a-f]{64}$'), + constraint analysis_source_snapshot_contract_check + check (length(btrim(source_contract_version)) between 1 and 128), + constraint analysis_source_snapshot_capture_check + check (maximum_available_time <= captured_at), + constraint analysis_source_snapshot_created_check + check (captured_at <= created_at) +); + +comment on table analysis_source_snapshot is + 'Immutable captured-source identity and latest evidence-availability time; ' + 'knowledge cutoffs belong to analysis_run, not the reusable snapshot.'; + +create table if not exists analysis_source_count ( + analysis_source_snapshot_id uuid not null + references analysis_source_snapshot (analysis_source_snapshot_id) + on delete cascade, + count_type_code text not null + references common_lookup_value (lookup_code), + count_value bigint not null, + primary key (analysis_source_snapshot_id, count_type_code), + constraint analysis_source_count_type_check + check (count_type_code in ( + 'analysis_count_source_row', + 'analysis_count_document', + 'analysis_count_thread', + 'analysis_count_lineage_node', + 'analysis_count_lineage_edge' + )), + constraint analysis_source_count_nonnegative_check + check (count_value >= 0) +); + +comment on table analysis_source_count is + 'One normalized aggregate reconciliation count per immutable snapshot and ' + 'count vocabulary; no source record is stored.'; + +create table if not exists analysis_run ( + analysis_run_id uuid primary key default uuid_generate_v4(), + analysis_source_snapshot_id uuid not null + references analysis_source_snapshot (analysis_source_snapshot_id), + run_kind_code text not null + references common_lookup_value (lookup_code), + requested_by_account_id uuid not null + references user_account (user_account_id), + idempotency_key text not null, + knowledge_cutoff timestamptz not null, + configuration_schema_version text not null, + configuration_sha256 text not null, + model_contract_sha256 text, + prompt_bundle_sha256 text, + code_revision_sha text not null, + requested_at timestamptz not null default now(), + constraint analysis_run_kind_check + check (run_kind_code in ( + 'analysis_run_lineage', + 'analysis_run_report', + 'analysis_run_tepp' + )), + constraint analysis_run_idempotency_key_check + check ( + idempotency_key = btrim(idempotency_key) + and length(idempotency_key) between 1 and 256 + and idempotency_key !~ '[[:cntrl:]]' + ), + constraint analysis_run_configuration_version_check + check ( + configuration_schema_version = btrim(configuration_schema_version) + and length(configuration_schema_version) between 1 and 128 + ), + constraint analysis_run_configuration_digest_check + check (configuration_sha256 ~ '^[0-9a-f]{64}$'), + constraint analysis_run_model_digest_check + check ( + model_contract_sha256 is null + or model_contract_sha256 ~ '^[0-9a-f]{64}$' + ), + constraint analysis_run_prompt_digest_check + check ( + prompt_bundle_sha256 is null + or prompt_bundle_sha256 ~ '^[0-9a-f]{64}$' + ), + constraint analysis_run_code_revision_check + check (code_revision_sha ~ '^(?:[0-9a-f]{40}|[0-9a-f]{64})$'), + constraint analysis_run_request_time_check + check (knowledge_cutoff <= requested_at), + unique (requested_by_account_id, idempotency_key) +); + +create index if not exists analysis_run_snapshot_idx + on analysis_run (analysis_source_snapshot_id); +create index if not exists analysis_run_kind_requested_idx + on analysis_run (run_kind_code, requested_at desc); +create index if not exists analysis_run_requester_idx + on analysis_run (requested_by_account_id, requested_at desc); + +comment on table analysis_run is + 'Immutable account-scoped analysis request bound to one snapshot, one ' + 'knowledge cutoff, and reproducibility digests; lifecycle is event-derived.'; + +create table if not exists analysis_run_scope ( + analysis_run_id uuid primary key + references analysis_run (analysis_run_id), + scope_kind_code text not null + references common_lookup_value (lookup_code), + corporate_entity_id uuid + references corporate_entity (corporate_entity_id), + process_unit_id uuid + references process_unit (process_unit_id), + scope_key text, + constraint analysis_run_scope_kind_check + check (scope_kind_code in ( + 'analysis_scope_all_visible', + 'analysis_scope_corporate_entity', + 'analysis_scope_process_unit', + 'analysis_scope_thread_group' + )), + constraint analysis_run_scope_shape_check + check ( + (scope_kind_code = 'analysis_scope_all_visible' + and corporate_entity_id is null + and process_unit_id is null + and scope_key is null) + or + (scope_kind_code = 'analysis_scope_corporate_entity' + and corporate_entity_id is not null + and process_unit_id is null + and scope_key is null) + or + (scope_kind_code = 'analysis_scope_process_unit' + and corporate_entity_id is null + and process_unit_id is not null + and scope_key is null) + or + (scope_kind_code = 'analysis_scope_thread_group' + and corporate_entity_id is null + and process_unit_id is null + and scope_key is not null + and scope_key = btrim(scope_key) + and length(scope_key) between 1 and 256 + and scope_key !~ '[[:cntrl:]]') + ) +); + +create index if not exists analysis_run_scope_entity_idx + on analysis_run_scope (corporate_entity_id) + where corporate_entity_id is not null; +create index if not exists analysis_run_scope_unit_idx + on analysis_run_scope (process_unit_id) + where process_unit_id is not null; + +comment on table analysis_run_scope is + 'One immutable authorization-relevant scope is required before lifecycle ' + 'evidence; process-unit ownership remains derivable from process_unit.'; + +create table if not exists analysis_run_status_event ( + analysis_run_id uuid not null + references analysis_run (analysis_run_id), + status_ordinal integer not null, + status_code text not null + references common_lookup_value (lookup_code), + occurred_at timestamptz not null, + recorded_at timestamptz not null default clock_timestamp(), + failure_code text, + retryable boolean not null default false, + primary key (analysis_run_id, status_ordinal), + constraint analysis_run_status_code_check + check (status_code in ( + 'analysis_status_pending', + 'analysis_status_running', + 'analysis_status_succeeded', + 'analysis_status_failed', + 'analysis_status_cancelled' + )), + constraint analysis_run_status_ordinal_check + check (status_ordinal >= 1), + constraint analysis_run_status_time_check + check (occurred_at <= recorded_at), + constraint analysis_run_status_failure_shape_check + check ( + (status_code = 'analysis_status_failed' + and failure_code is not null + and failure_code ~ '^[a-z][a-z0-9_]{0,127}$') + or + (status_code <> 'analysis_status_failed' + and failure_code is null + and retryable = false) + ) +); + +create index if not exists analysis_run_status_current_idx + on analysis_run_status_event (analysis_run_id, status_ordinal desc); + +comment on table analysis_run_status_event is + 'Append-only, contiguous, monotonic state-machine evidence; failure_code is ' + 'a bounded machine code and never contains raw provider or source payloads.'; + +create or replace function reject_analysis_source_snapshot_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_source_snapshot_is_immutable'; +end +$$; + +comment on function reject_analysis_source_snapshot_update() is + 'Rejects mutation of captured source identity and availability evidence.'; + +drop trigger if exists analysis_source_snapshot_update_reject + on analysis_source_snapshot; +create trigger analysis_source_snapshot_update_reject +before update on analysis_source_snapshot +for each row execute function reject_analysis_source_snapshot_update(); + +create or replace function reject_analysis_source_count_update() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_source_count_is_immutable'; +end +$$; + +comment on function reject_analysis_source_count_update() is + 'Rejects replacement of a snapshot aggregate; delete and reinsert is only ' + 'permitted before the snapshot is attached to a run.'; + +drop trigger if exists analysis_source_count_update_reject + on analysis_source_count; +create trigger analysis_source_count_update_reject +before update on analysis_source_count +for each row execute function reject_analysis_source_count_update(); + +create or replace function enforce_analysis_source_count_freeze() +returns trigger +language plpgsql +as $$ +declare + affected_snapshot_id uuid; +begin + if tg_op = 'DELETE' then + affected_snapshot_id := old.analysis_source_snapshot_id; + else + affected_snapshot_id := new.analysis_source_snapshot_id; + end if; + + -- Both count mutation and run creation lock this row first. That common + -- lock order closes the race between the final count write and first run. + perform 1 + from analysis_source_snapshot + where analysis_source_snapshot_id = affected_snapshot_id + for update; + + if exists ( + select 1 + from analysis_run + where analysis_source_snapshot_id = affected_snapshot_id + ) then + raise exception 'analysis_source_count_frozen_after_run'; + end if; + + if tg_op = 'DELETE' then + return old; + end if; + return new; +end +$$; + +comment on function enforce_analysis_source_count_freeze() is + 'Serializes count insert/delete against first run creation and rejects ' + 'changes after any run references the snapshot.'; + +drop trigger if exists analysis_source_count_freeze_guard + on analysis_source_count; +create trigger analysis_source_count_freeze_guard +before insert or delete on analysis_source_count +for each row execute function enforce_analysis_source_count_freeze(); + +create or replace function enforce_analysis_run_knowledge_cutoff() +returns trigger +language plpgsql +as $$ +declare + snapshot_available_time timestamptz; + snapshot_capture_time timestamptz; +begin + if new.requested_at > clock_timestamp() then + raise exception 'analysis_run_request_time_in_future'; + end if; + + select maximum_available_time, captured_at + into snapshot_available_time, snapshot_capture_time + from analysis_source_snapshot + where analysis_source_snapshot_id = new.analysis_source_snapshot_id + for update; + + if not found then + raise exception 'analysis_source_snapshot_not_found'; + end if; + if snapshot_available_time > new.knowledge_cutoff then + raise exception 'analysis_run_future_information_leakage'; + end if; + if snapshot_capture_time > new.requested_at then + raise exception 'analysis_run_snapshot_captured_after_request'; + end if; + return new; +end +$$; + +comment on function enforce_analysis_run_knowledge_cutoff() is + 'Locks the immutable snapshot and rejects run cutoffs earlier than the ' + 'latest admitted evidence or requests earlier than snapshot capture.'; + +drop trigger if exists analysis_run_knowledge_cutoff_guard + on analysis_run; +create trigger analysis_run_knowledge_cutoff_guard +before insert on analysis_run +for each row execute function enforce_analysis_run_knowledge_cutoff(); + +drop trigger if exists analysis_run_update_reject + on analysis_run; +drop trigger if exists analysis_run_mutation_reject + on analysis_run; +drop function if exists reject_analysis_run_update(); + +create or replace function reject_analysis_run_mutation() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_request_is_immutable'; +end +$$; + +comment on function reject_analysis_run_mutation() is + 'Rejects update or delete of actor, cutoff, idempotency, and reproducibility ' + 'evidence; run progress belongs to append-only status events.'; + +create trigger analysis_run_mutation_reject +before update or delete on analysis_run +for each row execute function reject_analysis_run_mutation(); + +create or replace function reject_analysis_run_scope_mutation() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_scope_is_immutable'; +end +$$; + +comment on function reject_analysis_run_scope_mutation() is + 'Rejects update or delete of the authorization-relevant scope attached to ' + 'an immutable analysis request.'; + +drop trigger if exists analysis_run_scope_mutation_reject + on analysis_run_scope; +create trigger analysis_run_scope_mutation_reject +before update or delete on analysis_run_scope +for each row execute function reject_analysis_run_scope_mutation(); + +create or replace function reject_analysis_run_status_mutation() +returns trigger +language plpgsql +as $$ +begin + raise exception 'analysis_run_status_event_is_append_only'; +end +$$; + +comment on function reject_analysis_run_status_mutation() is + 'Rejects update or delete of state-machine evidence.'; + +drop trigger if exists analysis_run_status_event_update_reject + on analysis_run_status_event; +create trigger analysis_run_status_event_update_reject +before update on analysis_run_status_event +for each row execute function reject_analysis_run_status_mutation(); + +drop trigger if exists analysis_run_status_event_delete_reject + on analysis_run_status_event; +create trigger analysis_run_status_event_delete_reject +before delete on analysis_run_status_event +for each row execute function reject_analysis_run_status_mutation(); + +create or replace function enforce_analysis_run_status_transition() +returns trigger +language plpgsql +as $$ +declare + previous_ordinal integer; + previous_status_code text; + previous_occurred_at timestamptz; + run_requested_at timestamptz; +begin + -- The immutable parent row is a per-run serialization lock. It prevents + -- concurrent writers from both accepting the same next ordinal. + select requested_at + into run_requested_at + from analysis_run + where analysis_run_id = new.analysis_run_id + for update; + + if not found then + raise exception 'analysis_run_not_found'; + end if; + if not exists ( + select 1 from analysis_run_scope + where analysis_run_id = new.analysis_run_id + ) then + raise exception 'analysis_run_scope_required'; + end if; + if new.occurred_at < run_requested_at then + raise exception 'analysis_run_status_before_request'; + end if; + new.recorded_at := clock_timestamp(); + + select status_ordinal, status_code, occurred_at + into previous_ordinal, previous_status_code, previous_occurred_at + from analysis_run_status_event + where analysis_run_id = new.analysis_run_id + order by status_ordinal desc + limit 1; + + if previous_ordinal is null then + if new.status_ordinal <> 1 + or new.status_code <> 'analysis_status_pending' then + raise exception 'analysis_run_first_status_must_be_pending'; + end if; + return new; + end if; + + if new.status_ordinal <> previous_ordinal + 1 then + raise exception 'analysis_run_status_ordinal_not_contiguous'; + end if; + if new.occurred_at < previous_occurred_at then + raise exception 'analysis_run_status_time_not_monotonic'; + end if; + + if previous_status_code = 'analysis_status_pending' then + if new.status_code not in ( + 'analysis_status_running', + 'analysis_status_cancelled' + ) then + raise exception 'analysis_run_status_transition_invalid'; + end if; + elsif previous_status_code = 'analysis_status_running' then + if new.status_code not in ( + 'analysis_status_succeeded', + 'analysis_status_failed', + 'analysis_status_cancelled' + ) then + raise exception 'analysis_run_status_transition_invalid'; + end if; + else + raise exception 'analysis_run_terminal_status_has_no_successor'; + end if; + + return new; +end +$$; + +comment on function enforce_analysis_run_status_transition() is + 'Serializes status appends and requires immutable scope, request-time ' + 'ordering, database-recorded time, legal transitions, and terminal finality.'; + +drop trigger if exists analysis_run_status_transition_guard + on analysis_run_status_event; +create trigger analysis_run_status_transition_guard +before insert on analysis_run_status_event +for each row execute function enforce_analysis_run_status_transition(); + +create or replace view analysis_run_current_status as +select distinct on (status_event.analysis_run_id) + status_event.analysis_run_id, + status_event.status_code, + status_event.status_ordinal, + status_event.occurred_at, + status_event.recorded_at, + status_event.failure_code, + status_event.retryable + from analysis_run_status_event as status_event + order by status_event.analysis_run_id, + status_event.status_ordinal desc; + +comment on view analysis_run_current_status is + 'Latest append-only status projection for each run; never a second mutable ' + 'lifecycle authority.'; + +commit; diff --git a/migrations/rollback/0012_analysis_run_registry.sql b/migrations/rollback/0012_analysis_run_registry.sql new file mode 100644 index 000000000..cf21fcb96 --- /dev/null +++ b/migrations/rollback/0012_analysis_run_registry.sql @@ -0,0 +1,70 @@ +-- Fail-closed rollback for migration 0012. +-- +-- Registry evidence must be exported or explicitly deleted under an approved +-- retention procedure before these objects can be removed. Re-running this +-- rollback after a successful empty rollback is safe. + +begin; + +do $$ +declare + relation_name text; + relation_has_rows boolean; +begin + foreach relation_name in array array[ + 'analysis_run_status_event', + 'analysis_run_scope', + 'analysis_run', + 'analysis_source_count', + 'analysis_source_snapshot' + ] loop + if to_regclass('public.' || relation_name) is not null then + execute format('select exists (select 1 from %I)', relation_name) + into relation_has_rows; + if relation_has_rows then + raise exception 'analysis_run_registry_not_empty'; + end if; + end if; + end loop; +end +$$; + +drop view if exists analysis_run_current_status; +drop table if exists analysis_run_status_event; +drop table if exists analysis_run_scope; +drop table if exists analysis_run; +drop table if exists analysis_source_count; +drop table if exists analysis_source_snapshot; + +drop function if exists enforce_analysis_run_status_transition(); +drop function if exists reject_analysis_run_status_mutation(); +drop function if exists reject_analysis_run_scope_mutation(); +drop function if exists reject_analysis_run_mutation(); +drop function if exists reject_analysis_run_update(); +drop function if exists enforce_analysis_run_knowledge_cutoff(); +drop function if exists enforce_analysis_source_count_freeze(); +drop function if exists reject_analysis_source_count_update(); +drop function if exists reject_analysis_source_snapshot_update(); + +delete from common_lookup_value + where lookup_code in ( + 'analysis_run_lineage', + 'analysis_run_report', + 'analysis_run_tepp', + 'analysis_status_pending', + 'analysis_status_running', + 'analysis_status_succeeded', + 'analysis_status_failed', + 'analysis_status_cancelled', + 'analysis_scope_all_visible', + 'analysis_scope_corporate_entity', + 'analysis_scope_process_unit', + 'analysis_scope_thread_group', + 'analysis_count_source_row', + 'analysis_count_document', + 'analysis_count_thread', + 'analysis_count_lineage_node', + 'analysis_count_lineage_edge' + ); + +commit; diff --git a/pyproject.toml b/pyproject.toml index 9a2272d3a..e3a80b99b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.71.0" +version = "0.72.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_analysis_run_orchestration.py b/tests/test_analysis_run_orchestration.py new file mode 100644 index 000000000..5852a4f49 --- /dev/null +++ b/tests/test_analysis_run_orchestration.py @@ -0,0 +1,142 @@ +"""Fail-closed contextual-orchestrator helper for analysis-run work.""" + +from __future__ import annotations + +import pytest + +from lineageweave.analysis_run_orchestration import ( + ContextualOrchestratorAnalysisRunClient, + NullAnalysisRunOrchestrationClient, + OrchestratorNotAvailable, + request_auto_completion, +) +from lineageweave.http_client import HttpClientError + + +def test_null_client_is_unavailable_and_does_not_invent_a_completion() -> None: + client = NullAnalysisRunOrchestrationClient() + assert client.available is False + with pytest.raises(RuntimeError, match="available"): + client.complete("summarize this run") + + +def test_request_auto_completion_fails_closed_without_a_base_url() -> None: + with pytest.raises(OrchestratorNotAvailable, match="base URL"): + request_auto_completion(None, "hello") + with pytest.raises(OrchestratorNotAvailable, match="base URL"): + request_auto_completion(" ", "hello") + with pytest.raises(OrchestratorNotAvailable, match="base URL"): + ContextualOrchestratorAnalysisRunClient(base_url="") + + +def test_request_auto_completion_uses_mode_auto_and_extracts_content() -> None: + recorded: dict[str, object] = {} + + def fake_poster(url: str, payload: dict, *, headers: dict, timeout: float) -> dict: + recorded["url"] = url + recorded["payload"] = payload + recorded["headers"] = headers + recorded["timeout"] = timeout + return {"choices": [{"message": {"content": "bounded summary"}}]} + + text = request_auto_completion( + "https://orchestrator.example.test/", + "summarize Demo Corp run", + api_key="demo-key", + timeout=12.0, + poster=fake_poster, + ) + assert text == "bounded summary" + assert recorded["url"] == "https://orchestrator.example.test/v1/chat/completions" + assert recorded["payload"]["mode"] == "auto" + assert recorded["payload"]["mode"] != "verify" + assert recorded["headers"]["authorization"] == "Bearer demo-key" + + def anonymous_poster(url: str, payload: dict, *, headers: dict, timeout: float) -> dict: + recorded["anonymous_headers"] = headers + return {"choices": [{"message": {"content": "ok"}}]} + + assert ( + request_auto_completion( + "https://orchestrator.example.test", + "hello", + poster=anonymous_poster, + ) + == "ok" + ) + assert recorded["anonymous_headers"] == {} + + +def test_request_auto_completion_fails_closed_on_invalid_mode_and_non_2xx() -> None: + def invalid_mode_poster(*_args, **_kwargs) -> dict: + return {"error": "invalid_mode"} + + with pytest.raises(OrchestratorNotAvailable, match="invalid_mode"): + request_auto_completion( + "https://orchestrator.example.test", + "hello", + poster=invalid_mode_poster, + ) + + def coded_invalid_mode(*_args, **_kwargs) -> dict: + return {"code": "invalid_mode"} + + with pytest.raises(OrchestratorNotAvailable, match="invalid_mode"): + request_auto_completion( + "https://orchestrator.example.test", + "hello", + poster=coded_invalid_mode, + ) + + def failing_poster(*_args, **_kwargs) -> dict: + raise HttpClientError("HTTP 503 from orchestrator.example.test") + + with pytest.raises(OrchestratorNotAvailable, match="503"): + request_auto_completion( + "https://orchestrator.example.test", + "hello", + poster=failing_poster, + ) + + def empty_poster(*_args, **_kwargs) -> dict: + return {"choices": []} + + with pytest.raises(OrchestratorNotAvailable, match="content"): + request_auto_completion( + "https://orchestrator.example.test", + "hello", + poster=empty_poster, + ) + + def blank_poster(*_args, **_kwargs) -> dict: + return {"choices": [{"message": {"content": " "}}]} + + with pytest.raises(OrchestratorNotAvailable, match="content"): + request_auto_completion( + "https://orchestrator.example.test", + "hello", + poster=blank_poster, + ) + + def non_string_poster(*_args, **_kwargs) -> dict: + return {"choices": [{"message": {"content": 12}}]} + + with pytest.raises(OrchestratorNotAvailable, match="content"): + request_auto_completion( + "https://orchestrator.example.test", + "hello", + poster=non_string_poster, + ) + + +def test_live_client_complete_uses_injected_auto_transport() -> None: + def fake_poster(*_args, **_kwargs) -> dict: + return {"choices": [{"message": {"content": "ok"}}]} + + client = ContextualOrchestratorAnalysisRunClient( + base_url="https://orchestrator.example.test", + api_key="demo-key", + poster=fake_poster, + ) + assert client.available is True + assert client.complete("hello") == "ok" diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py new file mode 100644 index 000000000..69a8642ed --- /dev/null +++ b/tests/test_analysis_run_registry_schema.py @@ -0,0 +1,1075 @@ +"""Real-PostgreSQL contracts for the Milestone 2.1 analysis-run registry. + +TDD split: + +- After migrations ``0001``–``0011`` the registry objects must be absent. +- After ``0012_analysis_run_registry.sql`` they must exist and enforce the + temporal, immutability, idempotency, orphan, and lifecycle contracts. + +Skipped unless a local PostgreSQL server is reachable +(``LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN``). +""" + +from __future__ import annotations + +import os +import re +import threading +import uuid +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import psycopg2 +import psycopg2.errors +import pytest +from psycopg2 import sql + +_ROOT = Path(__file__).resolve().parents[1] +_MIGRATIONS_DIR = _ROOT / "migrations" +_REGISTRY_MIGRATION = _MIGRATIONS_DIR / "0012_analysis_run_registry.sql" +_REGISTRY_ROLLBACK = _MIGRATIONS_DIR / "rollback" / "0012_analysis_run_registry.sql" +_POSTGRES_IMAGE = _ROOT / "docker" / "postgres-init" / "Dockerfile" +_ADMIN_DSN = os.environ.get( + "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" +) +_REQUIRED_TABLES = { + "analysis_source_snapshot", + "analysis_source_count", + "analysis_run", + "analysis_run_scope", + "analysis_run_status_event", +} +_REQUIRED_LOOKUP_CODES = { + "analysis_run_lineage", + "analysis_run_report", + "analysis_run_tepp", + "analysis_status_pending", + "analysis_status_running", + "analysis_status_succeeded", + "analysis_status_failed", + "analysis_status_cancelled", + "analysis_scope_all_visible", + "analysis_scope_corporate_entity", + "analysis_scope_process_unit", + "analysis_scope_thread_group", + "analysis_count_source_row", + "analysis_count_document", + "analysis_count_thread", + "analysis_count_lineage_node", + "analysis_count_lineage_edge", +} + + +def _postgres_available() -> bool: + """Return whether the configured administrator DSN is reachable.""" + + try: + psycopg2.connect(_ADMIN_DSN, connect_timeout=2).close() + return True + except psycopg2.OperationalError: + return False + + +def _database_dsn(database_name: str) -> str: + """Replace only the database path while preserving DSN query options.""" + + parsed = urlsplit(_ADMIN_DSN) + return urlunsplit(parsed._replace(path=f"/{database_name}")) + + +def _forward_migrations() -> list[Path]: + """Return ``0001``–``0012`` (and any later peers) in lexical order.""" + + return sorted( + path + for path in _MIGRATIONS_DIR.glob("*.sql") + if path.name[0:4].isdigit() + ) + + +def _migrations_through(last_name: str) -> list[Path]: + """Return forward migrations up to and including ``last_name``.""" + + selected: list[Path] = [] + for path in _forward_migrations(): + selected.append(path) + if path.name == last_name: + return selected + raise AssertionError(f"migration {last_name} is not in migrations/") + + +def _apply_sql_files(connection, paths: list[Path]) -> None: + """Execute each migration file against ``connection``.""" + + with connection.cursor() as cursor: + for path in paths: + cursor.execute(path.read_text(encoding="utf-8")) + + +def _public_tables(cursor) -> set[str]: + """Return public base-table names.""" + + cursor.execute( + "select table_name from information_schema.tables " + "where table_schema = 'public' and table_type = 'BASE TABLE'" + ) + return {row[0] for row in cursor.fetchall()} + + +def _public_views(cursor) -> set[str]: + """Return public view names.""" + + cursor.execute( + "select table_name from information_schema.views " + "where table_schema = 'public'" + ) + return {row[0] for row in cursor.fetchall()} + + +def _table_definition(migration: str, table_name: str) -> str: + """Return one table definition from the deterministic migration text.""" + + match = re.search( + rf"create table if not exists {re.escape(table_name)}\s*\((.*?)\n\);", + migration, + re.IGNORECASE | re.DOTALL, + ) + assert match is not None, table_name + return match.group(1) + + +@pytest.fixture +def ephemeral_db(): + """Yield ``(connection, dsn)`` for a throwaway database, then drop it.""" + + if not _postgres_available(): + pytest.skip("a reachable PostgreSQL administrator DSN is required") + database_name = f"lineageweave_registry_{uuid.uuid4().hex[:12]}" + admin_connection = psycopg2.connect(_ADMIN_DSN) + admin_connection.autocommit = True + with admin_connection.cursor() as cursor: + cursor.execute( + sql.SQL("create database {}").format(sql.Identifier(database_name)) + ) + dsn = _database_dsn(database_name) + try: + connection = psycopg2.connect(dsn) + try: + connection.autocommit = True + yield connection, dsn + finally: + connection.close() + finally: + with admin_connection.cursor() as cursor: + cursor.execute( + sql.SQL("drop database {}").format(sql.Identifier(database_name)) + ) + admin_connection.close() + + +@pytest.fixture +def schema_through_0011(ephemeral_db): + """Throwaway database migrated through the protected ``0001``–``0011`` chain.""" + + connection, dsn = ephemeral_db + _apply_sql_files(connection, _migrations_through("0011_post_chat_result.sql")) + return connection, dsn + + +@pytest.fixture +def registry_db(ephemeral_db): + """Throwaway database migrated through the registry schema.""" + + connection, dsn = ephemeral_db + _apply_sql_files(connection, _migrations_through("0012_analysis_run_registry.sql")) + return connection, dsn + + +def _insert_account(cursor, label: str = "operator") -> str: + """Insert one synthetic authenticated account and return its UUID.""" + + suffix = uuid.uuid4().hex + cursor.execute( + """ + insert into user_account + (external_subject_id, display_name, email_address) + values (%s, %s, %s) + returning user_account_id + """, + (f"{label}-{suffix}", f"{label.title()} User", f"{label}-{suffix}@example.test"), + ) + return str(cursor.fetchone()[0]) + + +def _insert_snapshot( + cursor, + *, + digest: str | None = None, + maximum_available_time: str = "2026-08-15T00:00:00Z", + captured_at: str = "2026-08-15T00:05:00Z", +) -> str: + """Insert one immutable source snapshot and return its UUID.""" + + cursor.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'source-contract-v1', %s, %s) + returning analysis_source_snapshot_id + """, + (digest or uuid.uuid4().hex, maximum_available_time, captured_at), + ) + return str(cursor.fetchone()[0]) + + +def _insert_run( + cursor, + *, + snapshot_id: str, + account_id: str, + idempotency_key: str, + knowledge_cutoff: str = "2026-08-15T00:30:00Z", + run_kind_code: str = "analysis_run_lineage", + requested_at: str = "2026-08-15T00:45:00Z", +) -> str: + """Insert one immutable account-scoped analysis request.""" + + cursor.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, %s, %s, %s, %s, 'lineage-run-v1', %s, %s, %s) + returning analysis_run_id + """, + ( + snapshot_id, + run_kind_code, + idempotency_key, + account_id, + knowledge_cutoff, + "b" * 64, + "c" * 40, + requested_at, + ), + ) + return str(cursor.fetchone()[0]) + + +def _insert_all_visible_scope(cursor, run_id: str) -> None: + """Attach the all-visible authorization scope to ``run_id``.""" + + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code) " + "values (%s, 'analysis_scope_all_visible')", + (run_id,), + ) + + +def test_registry_objects_are_absent_after_0011_upgrade(schema_through_0011) -> None: + """Protected main's 0001–0011 chain has no analysis-run registry yet.""" + + connection, _dsn = schema_through_0011 + with connection.cursor() as cursor: + tables = _public_tables(cursor) + views = _public_views(cursor) + assert _REQUIRED_TABLES.isdisjoint(tables) + assert "analysis_run_current_status" not in views + assert "analysis_run_current_status" not in tables + + +def test_registry_contract_is_normalized_and_has_one_temporal_authority() -> None: + """Static contract rejects a second mutable status table and duplicated clocks.""" + + assert _REGISTRY_MIGRATION.is_file(), "0012_analysis_run_registry.sql must exist" + assert _REGISTRY_ROLLBACK.is_file(), "rollback/0012_analysis_run_registry.sql must exist" + migration = _REGISTRY_MIGRATION.read_text(encoding="utf-8") + rollback = _REGISTRY_ROLLBACK.read_text(encoding="utf-8") + dockerfile = _POSTGRES_IMAGE.read_text(encoding="utf-8") + created_tables = set( + re.findall(r"create table if not exists\s+([a-z0-9_]+)", migration, re.I) + ) + assert _REQUIRED_TABLES <= created_tables + assert "analysis_run_records" not in created_tables + assert "metadata_payload" not in migration + assert "jsonb" not in migration.casefold() + assert " text[]" not in migration.casefold() + assert _REQUIRED_LOOKUP_CODES <= set( + re.findall(r"'(analysis_[a-z0-9_]+)'", migration) + ) + assert "0012_analysis_run_registry.sql" in dockerfile + assert "analysis_run_registry_not_empty" in rollback + + snapshot_definition = _table_definition(migration, "analysis_source_snapshot") + run_definition = _table_definition(migration, "analysis_run") + assert "maximum_available_time" in snapshot_definition + assert "knowledge_cutoff" not in snapshot_definition + assert "knowledge_cutoff" in run_definition + assert "requested_by_account_id uuid not null" in run_definition + assert "unique (requested_by_account_id, idempotency_key)" in run_definition + assert "enforce_analysis_run_knowledge_cutoff" in migration + assert "reject_analysis_source_snapshot_update" in migration + assert "reject_analysis_run_mutation" in migration + assert "reject_analysis_run_scope_mutation" in migration + assert "analysis_run_scope_required" in migration + assert "enforce_analysis_source_count_freeze" in migration + assert "enforce_analysis_run_status_transition" in migration + assert "create or replace view analysis_run_current_status" in migration.casefold() + + object_patterns = ( + r"create table if not exists\s+([a-z0-9_]+)", + r"create(?: unique)? index if not exists\s+([a-z0-9_]+)", + r"create or replace function\s+([a-z0-9_]+)", + r"create trigger\s+([a-z0-9_]+)", + r"create or replace view\s+([a-z0-9_]+)", + ) + for pattern in object_patterns: + for object_name in re.findall(pattern, migration, re.I): + assert len(object_name.split("_")) >= 2, object_name + + +def test_fresh_install_and_sequential_upgrade_converge(ephemeral_db) -> None: + """A new database and a 0001–0011 upgrade both reach the same registry.""" + + connection, _dsn = ephemeral_db + _apply_sql_files(connection, _migrations_through("0012_analysis_run_registry.sql")) + with connection.cursor() as cursor: + fresh_tables = _public_tables(cursor) + fresh_views = _public_views(cursor) + cursor.execute( + "select lookup_code from common_lookup_value " + "where lookup_code like 'analysis_%'" + ) + fresh_codes = {row[0] for row in cursor.fetchall()} + + connection2_name = f"lineageweave_upgrade_{uuid.uuid4().hex[:12]}" + admin_connection = psycopg2.connect(_ADMIN_DSN) + admin_connection.autocommit = True + with admin_connection.cursor() as cursor: + cursor.execute( + sql.SQL("create database {}").format(sql.Identifier(connection2_name)) + ) + try: + upgrade = psycopg2.connect(_database_dsn(connection2_name)) + try: + upgrade.autocommit = True + _apply_sql_files(upgrade, _migrations_through("0011_post_chat_result.sql")) + with upgrade.cursor() as cursor: + assert _REQUIRED_TABLES.isdisjoint(_public_tables(cursor)) + _apply_sql_files(upgrade, [_REGISTRY_MIGRATION]) + with upgrade.cursor() as cursor: + upgraded_tables = _public_tables(cursor) + upgraded_views = _public_views(cursor) + cursor.execute( + "select lookup_code from common_lookup_value " + "where lookup_code like 'analysis_%'" + ) + upgraded_codes = {row[0] for row in cursor.fetchall()} + finally: + upgrade.close() + finally: + with admin_connection.cursor() as cursor: + cursor.execute( + sql.SQL("drop database {}").format(sql.Identifier(connection2_name)) + ) + admin_connection.close() + + assert _REQUIRED_TABLES <= fresh_tables + assert _REQUIRED_TABLES <= upgraded_tables + assert "analysis_run_current_status" in fresh_views + assert "analysis_run_current_status" in upgraded_views + assert _REQUIRED_LOOKUP_CODES <= fresh_codes + assert fresh_codes == upgraded_codes + + +def test_registry_migration_is_idempotent(registry_db) -> None: + """Sequential migration replay preserves one object set.""" + + connection, _dsn = registry_db + _apply_sql_files(connection, [_REGISTRY_MIGRATION]) + with connection.cursor() as cursor: + tables = _public_tables(cursor) + views = _public_views(cursor) + assert _REQUIRED_TABLES <= tables + assert "analysis_run_current_status" in views + + +def test_registry_persists_scope_counts_and_legal_status_history(registry_db) -> None: + """A valid run keeps normalized scope, counts, and current status.""" + + connection, _dsn = registry_db + with connection.cursor() as cursor: + account_id = _insert_account(cursor) + snapshot_id = _insert_snapshot(cursor) + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_document', 12)", + (snapshot_id,), + ) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="synthetic-run-1", + ) + _insert_all_visible_scope(cursor, run_id) + cursor.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at) + values + (%s, 1, 'analysis_status_pending', '2026-08-15T01:00:01Z'), + (%s, 2, 'analysis_status_running', '2026-08-15T01:00:02Z'), + (%s, 3, 'analysis_status_succeeded', '2026-08-15T01:00:03Z') + """, + (run_id, run_id, run_id), + ) + cursor.execute( + "select status_code, status_ordinal from analysis_run_current_status " + "where analysis_run_id = %s", + (run_id,), + ) + assert cursor.fetchone() == ("analysis_status_succeeded", 3) + + +def test_snapshot_supports_multiple_run_owned_cutoffs_and_blocks_future_evidence( + registry_db, +) -> None: + """One capture is reusable, but each run must respect its own cutoff.""" + + connection, _dsn = registry_db + with connection.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + first_account_id = _insert_account(cursor, "first") + second_account_id = _insert_account(cursor, "second") + first_run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=first_account_id, + idempotency_key="cutoff-one", + knowledge_cutoff="2026-08-15T00:30:00Z", + ) + second_run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=second_account_id, + idempotency_key="cutoff-two", + knowledge_cutoff="2026-08-16T00:00:00Z", + requested_at="2026-08-16T00:30:00Z", + ) + assert first_run_id != second_run_id + with pytest.raises(psycopg2.errors.RaiseException): + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=first_account_id, + idempotency_key="future-leakage", + knowledge_cutoff="2026-08-14T23:59:59Z", + ) + + +def test_snapshot_counts_and_run_request_are_immutable(registry_db) -> None: + """Evidence and request configuration freeze before derivation starts.""" + + connection, _dsn = registry_db + with connection.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_document', 12)", + (snapshot_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "update analysis_source_snapshot set source_contract_version = 'x' " + "where analysis_source_snapshot_id = %s", + (snapshot_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "update analysis_source_count set count_value = 13 " + "where analysis_source_snapshot_id = %s", + (snapshot_id,), + ) + account_id = _insert_account(cursor) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="freeze-evidence", + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "update analysis_run set knowledge_cutoff = now() " + "where analysis_run_id = %s", + (run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_thread', 8)", + (snapshot_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "delete from analysis_source_count " + "where analysis_source_snapshot_id = %s", + (snapshot_id,), + ) + + +def test_idempotency_is_scoped_to_the_authenticated_account(registry_db) -> None: + """Two actors may use one opaque key; one actor may not reuse it.""" + + connection, _dsn = registry_db + with connection.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + first_account_id = _insert_account(cursor, "first") + second_account_id = _insert_account(cursor, "second") + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=first_account_id, + idempotency_key="shared-key", + ) + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=second_account_id, + idempotency_key="shared-key", + ) + with pytest.raises(psycopg2.errors.UniqueViolation): + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=first_account_id, + idempotency_key="shared-key", + ) + + +def test_concurrent_account_idempotency_has_one_winner(registry_db) -> None: + """Two concurrent inserts of the same account key leave exactly one run.""" + + connection, dsn = registry_db + with connection.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor, "racer") + + barrier = threading.Barrier(2) + outcomes: list[str] = [] + lock = threading.Lock() + + def _race() -> None: + raced = psycopg2.connect(dsn) + raced.autocommit = True + try: + with raced.cursor() as cursor: + barrier.wait(timeout=5) + try: + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="concurrent-key", + ) + result = "inserted" + except psycopg2.errors.UniqueViolation: + result = "conflict" + with lock: + outcomes.append(result) + finally: + raced.close() + + workers = [threading.Thread(target=_race) for _ in range(2)] + for worker in workers: + worker.start() + for worker in workers: + worker.join(timeout=10) + assert outcomes.count("inserted") == 1 + assert outcomes.count("conflict") == 1 + with connection.cursor() as cursor: + cursor.execute( + "select count(*) from analysis_run " + "where requested_by_account_id = %s and idempotency_key = %s", + (account_id, "concurrent-key"), + ) + assert cursor.fetchone()[0] == 1 + + +def test_registry_rejects_orphans_and_missing_actor(registry_db) -> None: + """Foreign keys reject dangling evidence; a run requires an actor.""" + + connection, _dsn = registry_db + missing = str(uuid.uuid4()) + with connection.cursor() as cursor: + with pytest.raises(psycopg2.errors.ForeignKeyViolation): + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_source_row', 1)", + (missing,), + ) + with pytest.raises(psycopg2.errors.ForeignKeyViolation): + cursor.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, 'analysis_run_lineage', 'orphan-run', %s, + '2026-08-15T00:30:00Z', 'lineage-run-v1', %s, %s, + '2026-08-15T00:45:00Z') + """, + (missing, missing, "b" * 64, "c" * 40), + ) + snapshot_id = _insert_snapshot(cursor) + with pytest.raises(psycopg2.errors.NotNullViolation): + cursor.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + knowledge_cutoff, configuration_schema_version, + configuration_sha256, code_revision_sha) + values (%s, 'analysis_run_report', 'missing-actor', now(), + 'report-run-v1', %s, %s) + """, + (snapshot_id, "d" * 64, "e" * 40), + ) + account_id = _insert_account(cursor) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="scope-orphan", + ) + with pytest.raises(psycopg2.errors.ForeignKeyViolation): + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code, corporate_entity_id) " + "values (%s, 'analysis_scope_corporate_entity', %s)", + (run_id, missing), + ) + with pytest.raises(psycopg2.errors.CheckViolation): + cursor.execute( + "insert into analysis_source_snapshot " + "(snapshot_sha256, source_contract_version, " + "maximum_available_time, captured_at) " + "values ('bad', 'source-contract-v1', now(), now())" + ) + with pytest.raises(psycopg2.errors.CheckViolation): + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_source_row', -1)", + (snapshot_id,), + ) + + +def test_scope_shapes_cover_all_four_vocabularies(registry_db) -> None: + """Each scope kind accepts only its declared foreign-key shape.""" + + connection, _dsn = registry_db + with connection.cursor() as cursor: + cursor.execute( + "insert into common_lookup_value " + "(lookup_category, lookup_code, lookup_label) values " + "('corporate_entity_level', 'group', 'Group')" + ) + cursor.execute( + "insert into corporate_entity " + "(corporate_entity_code, entity_name, entity_level_code) " + "values ('DEMO-CORP', 'Demo Corp', 'group') " + "returning corporate_entity_id" + ) + entity_id = str(cursor.fetchone()[0]) + cursor.execute( + "insert into process_unit " + "(corporate_entity_id, process_unit_code, process_unit_name) " + "values (%s, 'DEMO-PU', 'Demo process unit') " + "returning process_unit_id", + (entity_id,), + ) + process_unit_id = str(cursor.fetchone()[0]) + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + + entity_run = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="scope-entity", + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code, corporate_entity_id) " + "values (%s, 'analysis_scope_corporate_entity', %s)", + (entity_run, entity_id), + ) + unit_run = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="scope-unit", + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code, process_unit_id) " + "values (%s, 'analysis_scope_process_unit', %s)", + (unit_run, process_unit_id), + ) + thread_run = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="scope-thread", + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code, scope_key) " + "values (%s, 'analysis_scope_thread_group', 'A-100')", + (thread_run,), + ) + with pytest.raises(psycopg2.errors.CheckViolation): + visible_run = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="scope-bad-visible", + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code, corporate_entity_id) " + "values (%s, 'analysis_scope_all_visible', %s)", + (visible_run, entity_id), + ) + + +def test_status_history_enforces_shape_order_time_and_legal_transitions( + registry_db, +) -> None: + """Append-only status evidence is a serialized state machine.""" + + connection, _dsn = registry_db + with connection.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + first_run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="first-status", + ) + _insert_all_visible_scope(cursor, first_run_id) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_running', now())", + (first_run_id,), + ) + + second_run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="second-status", + ) + _insert_all_visible_scope(cursor, second_run_id) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z')", + (second_run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 3, 'analysis_status_running', " + "'2026-08-15T01:00:01Z')", + (second_run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 2, 'analysis_status_succeeded', " + "'2026-08-15T01:00:01Z')", + (second_run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 2, 'analysis_status_running', " + "'2026-08-15T01:00:02Z')", + (second_run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 3, 'analysis_status_succeeded', " + "'2026-08-15T01:00:01Z')", + (second_run_id,), + ) + with pytest.raises(psycopg2.errors.CheckViolation): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 3, 'analysis_status_failed', " + "'2026-08-15T01:00:03Z')", + (second_run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 3, 'analysis_status_succeeded', " + "'2026-08-15T01:00:03Z')", + (second_run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 4, 'analysis_status_running', " + "'2026-08-15T01:00:04Z')", + (second_run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "update analysis_run_status_event set retryable = true " + "where analysis_run_id = %s and status_ordinal = 3", + (second_run_id,), + ) + + +def test_run_scope_and_request_evidence_are_immutable(registry_db) -> None: + """Authorization scope and request identity cannot be rewritten or erased.""" + + connection, _dsn = registry_db + with connection.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="immutable-run", + ) + _insert_all_visible_scope(cursor, run_id) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "update analysis_run_scope set scope_kind_code = scope_kind_code " + "where analysis_run_id = %s", + (run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "delete from analysis_run_scope where analysis_run_id = %s", + (run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "delete from analysis_run where analysis_run_id = %s", + (run_id,), + ) + + +def test_status_requires_scope_and_cannot_predate_request(registry_db) -> None: + """Lifecycle evidence starts only after an immutable authorized request.""" + + connection, _dsn = registry_db + with connection.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="scoped-status", + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z')", + (run_id,), + ) + _insert_all_visible_scope(cursor, run_id) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T00:44:59Z')", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at, recorded_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z', '2099-01-01T00:00:00Z') " + "returning recorded_at", + (run_id,), + ) + recorded_at = cursor.fetchone()[0] + assert recorded_at.year < 2099 + + +def test_machine_codes_and_canonical_idempotency_are_fail_closed(registry_db) -> None: + """Audit identifiers are canonical and failure details stay machine-safe.""" + + connection, _dsn = registry_db + with connection.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + with pytest.raises(psycopg2.errors.RaiseException): + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="future-request", + requested_at="2099-01-01T00:00:00Z", + ) + with pytest.raises(psycopg2.errors.CheckViolation): + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key=" padded-key ", + ) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="machine-safe", + ) + _insert_all_visible_scope(cursor, run_id) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z')", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 2, 'analysis_status_running', " + "'2026-08-15T01:00:00Z')", + (run_id,), + ) + with pytest.raises(psycopg2.errors.CheckViolation): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at, " + "failure_code, retryable) " + "values (%s, 3, 'analysis_status_failed', " + "'2026-08-15T01:00:00Z', 'provider timeout', true)", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at, " + "failure_code, retryable) " + "values (%s, 3, 'analysis_status_failed', " + "'2026-08-15T01:00:00Z', 'provider_timeout', true)", + (run_id,), + ) + + +def test_pending_may_cancel_and_running_may_fail(registry_db) -> None: + """Legal non-success terminals are pending→cancelled and running→failed.""" + + connection, _dsn = registry_db + with connection.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + cancelled_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="cancel-pending", + ) + _insert_all_visible_scope(cursor, cancelled_id) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z')", + (cancelled_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 2, 'analysis_status_cancelled', " + "'2026-08-15T01:00:01Z')", + (cancelled_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 3, 'analysis_status_running', " + "'2026-08-15T01:00:02Z')", + (cancelled_id,), + ) + + failed_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="fail-running", + ) + _insert_all_visible_scope(cursor, failed_id) + cursor.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at, + failure_code, retryable) + values + (%s, 1, 'analysis_status_pending', '2026-08-15T01:00:00Z', + null, false), + (%s, 2, 'analysis_status_running', '2026-08-15T01:00:01Z', + null, false), + (%s, 3, 'analysis_status_failed', '2026-08-15T01:00:02Z', + 'tepp_unavailable', false) + """, + (failed_id, failed_id, failed_id), + ) + cursor.execute( + "select status_code from analysis_run_current_status " + "where analysis_run_id = %s", + (failed_id,), + ) + assert cursor.fetchone()[0] == "analysis_status_failed" + + +def test_rollback_refuses_data_loss_then_removes_an_empty_registry(registry_db) -> None: + """Downgrade fails closed until audit evidence is explicitly removed.""" + + connection, _dsn = registry_db + rollback_sql = _REGISTRY_ROLLBACK.read_text(encoding="utf-8") + with connection.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute(rollback_sql) + cursor.execute("rollback") + with connection.cursor() as cursor: + cursor.execute( + "delete from analysis_source_snapshot " + "where analysis_source_snapshot_id = %s", + (snapshot_id,), + ) + cursor.execute(rollback_sql) + cursor.execute("select to_regclass('public.analysis_run')") + assert cursor.fetchone()[0] is None + cursor.execute(rollback_sql) + _apply_sql_files(connection, [_REGISTRY_MIGRATION]) + cursor.execute("select to_regclass('public.analysis_run')") + assert cursor.fetchone()[0] == "analysis_run" diff --git a/tests/test_documentation_hygiene.py b/tests/test_documentation_hygiene.py new file mode 100644 index 000000000..40ef4b56b --- /dev/null +++ b/tests/test_documentation_hygiene.py @@ -0,0 +1,58 @@ +"""Permanent hygiene checks for committed architecture-decision records.""" + +from __future__ import annotations + +import re +from collections import Counter +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[1] +_ADR_DIRECTORY = _ROOT / "docs" / "adr" +_ADR_NAME = re.compile(r"^(?P[0-9]{4})-.+\.md$") +_FORBIDDEN_MARKERS = ( + "PLACEHOLDER_DO_NOT_WRITE", + "TODO_WRITE_ADR", +) + + +def test_adr_numbers_are_unique_and_documents_are_not_placeholders() -> None: + """Every committed ADR number identifies one substantive UTF-8 document.""" + + paths = sorted(_ADR_DIRECTORY.glob("*.md")) + assert paths, "the repository must contain architecture-decision records" + + numbered_paths: list[tuple[str, Path]] = [] + for path in paths: + match = _ADR_NAME.fullmatch(path.name) + assert match is not None, f"ADR filename is not numbered: {path.name}" + numbered_paths.append((match.group("number"), path)) + + content = path.read_text(encoding="utf-8") + assert content.strip(), f"ADR is empty: {path.relative_to(_ROOT)}" + for marker in _FORBIDDEN_MARKERS: + assert marker not in content, ( + f"ADR contains forbidden placeholder {marker!r}: " + f"{path.relative_to(_ROOT)}" + ) + + counts = Counter(number for number, _ in numbered_paths) + duplicates = sorted(number for number, count in counts.items() if count > 1) + assert duplicates == [], f"duplicate ADR numbers: {duplicates}" + assert "0013" in counts, "ADR 0013 is already taken by adaptive orchestration" + assert "0014" in counts, "the analysis-run registry must be ADR 0014" + + +def test_registry_docs_use_migration_0012_and_version_072() -> None: + """The additive registry slice must not reuse stacked 0018 / 0.78.0 numbers.""" + + adr = (_ADR_DIRECTORY / "0014-normalized-analysis-run-registry.md").read_text( + encoding="utf-8" + ) + changelog = (_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + assert "0012_analysis_run_registry.sql" in adr + assert "0018_analysis_run_registry.sql" not in adr + assert "## [0.72.0]" in changelog + assert "0.78.0" not in adr + from lineageweave import __version__ + + assert __version__ == "0.72.0" diff --git a/tests/test_public_content_boundary.py b/tests/test_public_content_boundary.py new file mode 100644 index 000000000..8223a6d39 --- /dev/null +++ b/tests/test_public_content_boundary.py @@ -0,0 +1,100 @@ +"""Public-git denylist for the analysis-run registry slice. + +The registry may mention synthetic Demo Corp and aggregate ranges. It must +not ship source-export table names, industrial-group or source-org names, +raw row identifiers, image bytes, credentials, or exact private counts. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[1] +_SCAN_ROOTS = ( + _ROOT / "docs", + _ROOT / "migrations", + _ROOT / "lineageweave", + _ROOT / "CHANGELOG.md", + _ROOT / "CHANGELOG.d", + _ROOT / "ARCHITECTURE.md", + _ROOT / "README.md", +) +_SKIP_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".lock"} +_BASE64_IMAGE = re.compile(r"data:image/[^;]+;base64,[A-Za-z0-9+/]{80,}") +_CREDENTIAL_DSN = re.compile(r"postgres(?:ql)?://[^/\s:]+:[^@\s/]+@") +_EXACT_PRIVATE_COUNT = re.compile( + r"\bexactly\s+\d{3,}\s+(?:rows?|documents?|threads?|nodes?|edges?)\b", + re.IGNORECASE, +) +_RAW_ROW_ID = re.compile( + r"\b(?:row_id|source_row_id|export_row_id)\s*[:=]\s*['\"]?\d{4,}", + re.IGNORECASE, +) +_FORBIDDEN_ASSEMBLED = ( + ("source_export", "_"), + ("src_export", "_"), + ("industrial", "_group"), + ("source_org", "_name"), +) + + +def _iter_public_text_files() -> list[Path]: + """Return committed-style text files in the public documentation surface.""" + + files: list[Path] = [] + for root in _SCAN_ROOTS: + if root.is_file(): + files.append(root) + continue + if not root.is_dir(): + continue + for path in root.rglob("*"): + if not path.is_file() or path.suffix.lower() in _SKIP_SUFFIXES: + continue + files.append(path) + return files + + +def test_public_registry_surface_keeps_private_source_material_out() -> None: + """Docs, migrations, and package text stay inside the synthetic boundary.""" + + allowed_demo = "Demo Corp" + findings: list[str] = [] + for path in _iter_public_text_files(): + text = path.read_text(encoding="utf-8") + relative = str(path.relative_to(_ROOT)) + if _BASE64_IMAGE.search(text): + findings.append(f"{relative}: base64 image payload") + if _CREDENTIAL_DSN.search(text) and "lineageweave_dev_only" not in text: + findings.append(f"{relative}: credential-shaped DSN") + if "COPILOT_GITHUB_TOKEN" in text: + findings.append(f"{relative}: COPILOT_GITHUB_TOKEN") + if _EXACT_PRIVATE_COUNT.search(text): + findings.append(f"{relative}: exact private count") + if _RAW_ROW_ID.search(text): + findings.append(f"{relative}: raw row identifier") + for left, right in _FORBIDDEN_ASSEMBLED: + if f"{left}{right}" in text: + findings.append(f"{relative}: forbidden {left}{right}") + if "Corp" in text and allowed_demo not in text and "Test Corp" not in text: + if re.search(r"\b[A-Z][A-Za-z]+ Corp\b", text): + # Synthetic Acme appears in the 0001 schema commentary only. + if "Acme" not in text and relative != "migrations/0001_initial_schema.sql": + findings.append(f"{relative}: non-demo corporate name") + assert findings == [] + + +def test_registry_docs_allow_only_aggregate_ranges() -> None: + """Buyer-facing registry docs speak in ranges, not private cardinalities.""" + + registry_docs = [ + _ROOT / "docs" / "analysis-run-registry.md", + _ROOT / "docs" / "adr" / "0014-normalized-analysis-run-registry.md", + _ROOT / "docs" / "doctoring" / "ANALYSIS_RUN_REGISTRY_REFERENCES.md", + ] + for path in registry_docs: + text = path.read_text(encoding="utf-8") + assert path.is_file() + assert "aggregate" in text.casefold() + assert not _EXACT_PRIVATE_COUNT.search(text) diff --git a/tests/test_tepp_client.py b/tests/test_tepp_client.py index 8c2509fb9..02f4cbf32 100644 --- a/tests/test_tepp_client.py +++ b/tests/test_tepp_client.py @@ -2,7 +2,15 @@ import pytest -from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable +from lineageweave.http_client import HttpClientError +from lineageweave.tepp_client import ( + AnalysisRunRequest, + TeppClient, + TeppNotAvailable, + analysis_run_request_from_registry, + create_https_analysis_run_transport, + create_in_process_tepp_transport, +) def _sample_request() -> AnalysisRunRequest: @@ -49,3 +57,94 @@ def fake_transport(payload: dict) -> dict: assert result == {"status": "accepted"} assert received["contract_version"] == 1 assert received["snapshot_id"] == "demo-snapshot-1" + + +def test_analysis_run_request_from_registry_requires_snapshot_and_cutoff() -> None: + request = analysis_run_request_from_registry( + snapshot_id=" snap-1 ", + knowledge_cutoff="2026-08-15T00:30:00Z", + idempotency_key="demo-run-1", + tenant_workspace_id="demo-workspace", + model_contract_version="v1", + output_profile="graphml", + ) + assert request.snapshot_id == "snap-1" + assert request.knowledge_cutoff == "2026-08-15T00:30:00Z" + with pytest.raises(ValueError, match="snapshot_id"): + analysis_run_request_from_registry( + snapshot_id=" ", + knowledge_cutoff="2026-08-15T00:30:00Z", + idempotency_key="demo-run-1", + tenant_workspace_id="demo-workspace", + model_contract_version="v1", + output_profile="graphml", + ) + with pytest.raises(ValueError, match="knowledge_cutoff"): + analysis_run_request_from_registry( + snapshot_id="snap-1", + knowledge_cutoff="", + idempotency_key="demo-run-1", + tenant_workspace_id="demo-workspace", + model_contract_version="v1", + output_profile="graphml", + ) + + +def test_https_transport_fails_closed_unless_https_post_is_injected() -> None: + with pytest.raises(TeppNotAvailable, match="HTTPS"): + create_https_analysis_run_transport("http://tepp.example.test") + with pytest.raises(TeppNotAvailable, match="HTTPS"): + create_https_analysis_run_transport("") + with pytest.raises(TeppNotAvailable, match="HTTPS"): + create_https_analysis_run_transport("file:///tmp/tepp") + with pytest.raises(TeppNotAvailable, match="HTTPS"): + create_https_analysis_run_transport("https://") + + recorded: dict[str, object] = {} + + def fake_poster(url: str, payload: dict, *, headers: dict, timeout: float) -> dict: + recorded["url"] = url + recorded["payload"] = payload + recorded["headers"] = headers + recorded["timeout"] = timeout + return {"status": "accepted"} + + transport = create_https_analysis_run_transport( + "https://tepp.example.test/base/", + api_key="demo-key", + poster=fake_poster, + timeout=7.5, + ) + result = TeppClient(transport=transport).submit_analysis_run(_sample_request()) + assert result == {"status": "accepted"} + assert recorded["url"] == "https://tepp.example.test/base/v1/analysis-runs" + assert recorded["payload"]["snapshot_id"] == "demo-snapshot-1" + assert recorded["payload"]["knowledge_cutoff"] == "2026-01-01" + assert recorded["headers"]["authorization"] == "Bearer demo-key" + assert recorded["timeout"] == 7.5 + + def failing_poster(*_args, **_kwargs) -> dict: + raise HttpClientError("HTTP 503 from tepp.example.test") + + closed = create_https_analysis_run_transport( + "https://tepp.example.test", + poster=failing_poster, + ) + with pytest.raises(TeppNotAvailable, match="503"): + closed(_sample_request().to_json()) + + anonymous = create_https_analysis_run_transport( + "https://tepp.example.test", + poster=fake_poster, + ) + anonymous(_sample_request().to_json()) + assert "authorization" not in recorded["headers"] + + +def test_in_process_tepp_api_transport_is_injected_explicitly() -> None: + def tepp_api(payload: dict) -> dict: + return {"status": "accepted", "snapshot_id": payload["snapshot_id"]} + + transport = create_in_process_tepp_transport(tepp_api) + result = TeppClient(transport=transport).submit_analysis_run(_sample_request()) + assert result == {"status": "accepted", "snapshot_id": "demo-snapshot-1"} diff --git a/uv.lock b/uv.lock index 1964f34b9..e7cfaab48 100644 --- a/uv.lock +++ b/uv.lock @@ -355,7 +355,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.71.0" +version = "0.72.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From b4f3cc0457704ebb9acc45b6c84e1d1f233b1cfa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:23:49 +0000 Subject: [PATCH 2/7] test: fix registry digest length and public-content scan Use a 64-character SHA-256-shaped snapshot digest, accept the fail-closed missing-snapshot trigger, skip binary files in the public-content walk, and match the HTTPS transport error case-insensitively. Co-authored-by: Seongho Bae --- tests/test_analysis_run_registry_schema.py | 82 +++++++++++----------- tests/test_public_content_boundary.py | 4 ++ tests/test_tepp_client.py | 8 +-- 3 files changed, 49 insertions(+), 45 deletions(-) diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index 69a8642ed..cd038c5e2 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -218,7 +218,7 @@ def _insert_snapshot( values (%s, 'source-contract-v1', %s, %s) returning analysis_source_snapshot_id """, - (digest or uuid.uuid4().hex, maximum_available_time, captured_at), + (digest or uuid.uuid4().hex + uuid.uuid4().hex, maximum_available_time, captured_at), ) return str(cursor.fetchone()[0]) @@ -612,7 +612,7 @@ def test_registry_rejects_orphans_and_missing_actor(registry_db) -> None: "(%s, 'analysis_count_source_row', 1)", (missing,), ) - with pytest.raises(psycopg2.errors.ForeignKeyViolation): + with pytest.raises(psycopg2.errors.RaiseException, match="snapshot_not_found"): cursor.execute( """ insert into analysis_run @@ -626,46 +626,46 @@ def test_registry_rejects_orphans_and_missing_actor(registry_db) -> None: """, (missing, missing, "b" * 64, "c" * 40), ) - snapshot_id = _insert_snapshot(cursor) - with pytest.raises(psycopg2.errors.NotNullViolation): - cursor.execute( - """ - insert into analysis_run - (analysis_source_snapshot_id, run_kind_code, idempotency_key, - knowledge_cutoff, configuration_schema_version, - configuration_sha256, code_revision_sha) - values (%s, 'analysis_run_report', 'missing-actor', now(), - 'report-run-v1', %s, %s) - """, - (snapshot_id, "d" * 64, "e" * 40), - ) - account_id = _insert_account(cursor) - run_id = _insert_run( - cursor, - snapshot_id=snapshot_id, - account_id=account_id, - idempotency_key="scope-orphan", - ) - with pytest.raises(psycopg2.errors.ForeignKeyViolation): - cursor.execute( - "insert into analysis_run_scope " - "(analysis_run_id, scope_kind_code, corporate_entity_id) " - "values (%s, 'analysis_scope_corporate_entity', %s)", - (run_id, missing), - ) - with pytest.raises(psycopg2.errors.CheckViolation): - cursor.execute( - "insert into analysis_source_snapshot " - "(snapshot_sha256, source_contract_version, " - "maximum_available_time, captured_at) " - "values ('bad', 'source-contract-v1', now(), now())" - ) - with pytest.raises(psycopg2.errors.CheckViolation): - cursor.execute( - "insert into analysis_source_count values " - "(%s, 'analysis_count_source_row', -1)", - (snapshot_id,), + snapshot_id = _insert_snapshot(cursor) + with pytest.raises(psycopg2.errors.CheckViolation): + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_source_row', -1)", + (snapshot_id,), + ) + with pytest.raises(psycopg2.errors.CheckViolation): + cursor.execute( + "insert into analysis_source_snapshot " + "(snapshot_sha256, source_contract_version, " + "maximum_available_time, captured_at) " + "values ('bad', 'source-contract-v1', now(), now())" + ) + with pytest.raises(psycopg2.errors.NotNullViolation): + cursor.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + knowledge_cutoff, configuration_schema_version, + configuration_sha256, code_revision_sha) + values (%s, 'analysis_run_report', 'missing-actor', now(), + 'report-run-v1', %s, %s) + """, + (snapshot_id, "d" * 64, "e" * 40), + ) + account_id = _insert_account(cursor) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="scope-orphan", ) + with pytest.raises(psycopg2.errors.ForeignKeyViolation): + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code, corporate_entity_id) " + "values (%s, 'analysis_scope_corporate_entity', %s)", + (run_id, missing), + ) def test_scope_shapes_cover_all_four_vocabularies(registry_db) -> None: diff --git a/tests/test_public_content_boundary.py b/tests/test_public_content_boundary.py index 8223a6d39..0ddbe7626 100644 --- a/tests/test_public_content_boundary.py +++ b/tests/test_public_content_boundary.py @@ -52,6 +52,10 @@ def _iter_public_text_files() -> list[Path]: for path in root.rglob("*"): if not path.is_file() or path.suffix.lower() in _SKIP_SUFFIXES: continue + try: + path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue files.append(path) return files diff --git a/tests/test_tepp_client.py b/tests/test_tepp_client.py index 02f4cbf32..1f8c6e015 100644 --- a/tests/test_tepp_client.py +++ b/tests/test_tepp_client.py @@ -91,13 +91,13 @@ def test_analysis_run_request_from_registry_requires_snapshot_and_cutoff() -> No def test_https_transport_fails_closed_unless_https_post_is_injected() -> None: - with pytest.raises(TeppNotAvailable, match="HTTPS"): + with pytest.raises(TeppNotAvailable, match="https://"): create_https_analysis_run_transport("http://tepp.example.test") - with pytest.raises(TeppNotAvailable, match="HTTPS"): + with pytest.raises(TeppNotAvailable, match="https://"): create_https_analysis_run_transport("") - with pytest.raises(TeppNotAvailable, match="HTTPS"): + with pytest.raises(TeppNotAvailable, match="https://"): create_https_analysis_run_transport("file:///tmp/tepp") - with pytest.raises(TeppNotAvailable, match="HTTPS"): + with pytest.raises(TeppNotAvailable, match="https://"): create_https_analysis_run_transport("https://") recorded: dict[str, object] = {} From 214588fe004e2c911da215f90ff443b47fdcf92d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:44:10 +0000 Subject: [PATCH 3/7] test: cover capture-after-request, status delete, and count freeze Reject inverted snapshot clocks, prove status events cannot be deleted, and show concurrent count writes fail after the first run. List the new null analysis-run orchestration client in AGENTS.md. Co-authored-by: Seongho Bae --- .gitignore | 1 + AGENTS.md | 7 +- tests/test_analysis_run_registry_schema.py | 117 +++++++++++++++++++++ 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 92f76829b..bc23fa357 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ __pycache__/ *.pyc .pytest_cache/ +.coverage .venv/ *.egg-info/ .DS_Store diff --git a/AGENTS.md b/AGENTS.md index 735988f09..c1e0dfd3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,12 +46,13 @@ does it (`gh repo list ContextualWisdomLab`). `NullEmbeddingClient`, `NullAdjudicationClient`, `NullKeymanExtractionClient`, `NullEntityRelationshipClient`, -`NullPostSummaryClient`, `NullPostChatClient`, and -`NullCommitmentExtractionClient` (and any new channel client you add) +`NullPostSummaryClient`, `NullPostChatClient`, +`NullCommitmentExtractionClient`, and +`NullAnalysisRunOrchestrationClient` (and any new channel client you add) must set `available = False` and make their channel dropped + renormalized (`reconstruct.active_weights`), never silently return a placeholder score, invented Keyman, guessed relationship, fabricated -summary/chat, or invented commitment. A missing signal and a +summary/chat, invented commitment, or invented analysis-run completion. A missing signal and a confidently-negative signal are different things. Keyman extraction, entity-relationship classification, post summary, in-popup chat, and commitment derivation go through contextual-orchestrator the same way diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index cd038c5e2..fb799920b 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -1073,3 +1073,120 @@ def test_rollback_refuses_data_loss_then_removes_an_empty_registry(registry_db) _apply_sql_files(connection, [_REGISTRY_MIGRATION]) cursor.execute("select to_regclass('public.analysis_run')") assert cursor.fetchone()[0] == "analysis_run" + + +def test_capture_after_request_and_availability_after_capture_are_rejected( + registry_db, +) -> None: + """The leakage guard rejects inverted snapshot and request clocks.""" + + connection, _dsn = registry_db + with connection.cursor() as cursor: + with pytest.raises(psycopg2.errors.CheckViolation): + _insert_snapshot( + cursor, + maximum_available_time="2026-08-15T00:10:00Z", + captured_at="2026-08-15T00:05:00Z", + ) + snapshot_id = _insert_snapshot( + cursor, + captured_at="2026-08-15T01:00:00Z", + ) + account_id = _insert_account(cursor) + with pytest.raises(psycopg2.errors.RaiseException): + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="late-capture", + requested_at="2026-08-15T00:45:00Z", + ) + + +def test_status_events_cannot_be_deleted(registry_db) -> None: + """Append-only lifecycle evidence rejects delete as well as update.""" + + connection, _dsn = registry_db + with connection.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="delete-status", + ) + _insert_all_visible_scope(cursor, run_id) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "'2026-08-15T01:00:00Z')", + (run_id,), + ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "delete from analysis_run_status_event " + "where analysis_run_id = %s", + (run_id,), + ) + + +def test_concurrent_count_writes_freeze_once_a_run_exists(registry_db) -> None: + """Count insert and first run share the snapshot lock; later counts fail.""" + + connection, dsn = registry_db + with connection.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_document', 3)", + (snapshot_id,), + ) + _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="freeze-race", + ) + + barrier = threading.Barrier(2) + outcomes: list[str] = [] + lock = threading.Lock() + + def _race(count_type: str) -> None: + raced = psycopg2.connect(dsn) + raced.autocommit = True + try: + with raced.cursor() as cursor: + barrier.wait(timeout=5) + try: + cursor.execute( + "insert into analysis_source_count values (%s, %s, 1)", + (snapshot_id, count_type), + ) + result = "inserted" + except psycopg2.errors.RaiseException: + result = "frozen" + with lock: + outcomes.append(result) + finally: + raced.close() + + workers = [ + threading.Thread(target=_race, args=("analysis_count_thread",)), + threading.Thread(target=_race, args=("analysis_count_source_row",)), + ] + for worker in workers: + worker.start() + for worker in workers: + worker.join(timeout=10) + assert outcomes == ["frozen", "frozen"] + with connection.cursor() as cursor: + cursor.execute( + "select count(*) from analysis_source_count " + "where analysis_source_snapshot_id = %s", + (snapshot_id,), + ) + assert cursor.fetchone()[0] == 1 From f0713ed0bd4a79ce0b27a483bfcf26da7bd806be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:11:45 +0000 Subject: [PATCH 4/7] test: split orphan asserts and cover leftover clock and pending-failed Keep one pytest.raises per statement so negative-count, digest, actor, and dangling-entity checks actually run. Reject knowledge_cutoff after requested_at and pending-to-failed. Soften buyer copy: this slice is schema only until the atomic create exists. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 10 ++- CHANGELOG.d/0.72.0-analysis-run-registry.md | 10 ++- CHANGELOG.md | 16 ++-- docs/analysis-run-registry.md | 16 ++-- tests/test_analysis_run_registry_schema.py | 97 ++++++++++++--------- 5 files changed, 90 insertions(+), 59 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e62118d3e..bdad28eb5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -140,12 +140,14 @@ identities and content) and `migrations/0001_initial_schema.sql` for the (skipped without a reachable PostgreSQL server, same pattern as the real-provider LLM tests). -Milestone 2.1 adds an additive analysis-run registry in +Milestone 2.1 adds an additive analysis-run registry schema in `migrations/0012_analysis_run_registry.sql` (ADR 0014): immutable source snapshots, aggregate counts, account-scoped runs, one product scope, and -append-only status events with a derived current-status view. Beginner -ERD: [`docs/analysis-run-registry.md`](docs/analysis-run-registry.md). -This slice has no public CRUD API. +append-only status events with a derived current-status view. A raw insert +can store a run with no scope, no counts, and no pending event until a +later write API creates those rows atomically. Beginner ERD: +[`docs/analysis-run-registry.md`](docs/analysis-run-registry.md). This +slice has no public CRUD API. ### Local infrastructure (Docker Compose) diff --git a/CHANGELOG.d/0.72.0-analysis-run-registry.md b/CHANGELOG.d/0.72.0-analysis-run-registry.md index 0fd67f263..5c743bfb2 100644 --- a/CHANGELOG.d/0.72.0-analysis-run-registry.md +++ b/CHANGELOG.d/0.72.0-analysis-run-registry.md @@ -2,10 +2,12 @@ ## Added -- Migration `0012_analysis_run_registry.sql` adds the Milestone 2.1 registry: - immutable source snapshots, non-negative aggregate counts, account-scoped - analysis runs, one authorization scope per run, append-only status events, - and a derived current-status view. +- Migration `0012_analysis_run_registry.sql` adds the Milestone 2.1 + registry schema: immutable source snapshots, non-negative aggregate + counts, account-scoped analysis runs, one authorization scope per run, + append-only status events, and a derived current-status view. A raw + insert can store a run with no scope, no counts, and no pending event + until a later write API creates those rows atomically. - Fail-closed TEPP helpers bind `AnalysisRunRequest` to `snapshot_id` and `knowledge_cutoff` without forking TEPP arithmetic. - New contextual-orchestrator helpers request `mode="auto"` only. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a29c7ab3..297e32f01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,16 @@ All notable changes to this project are documented here. Format follows ### Added -- Additive analysis-run registry (`migrations/0012_analysis_run_registry.sql`, - ADR 0014). Operators can persist an immutable source snapshot, aggregate - reconciliation counts, an authenticated Demo Corp requester, run-owned - knowledge cutoff, product scope, and append-only legal status events. - Current status is a view. There is no public CRUD API in this slice. - TEPP stays fail-closed unless an HTTPS `POST /v1/analysis-runs` or +- Additive analysis-run registry schema + (`migrations/0012_analysis_run_registry.sql`, ADR 0014). The tables can + hold an immutable source snapshot, aggregate reconciliation counts, an + authenticated Demo Corp requester, run-owned knowledge cutoff, product + scope, and append-only legal status events. Current status is a view. + This slice is schema only: a raw insert can store a run with no scope, + no counts, and no pending event. Treat a run as recorded only after a + later write API stores snapshot, counts, run, scope, and pending in one + transaction (ADR 0014 follow-up 1). There is no public CRUD API. TEPP + stays fail-closed unless an HTTPS `POST /v1/analysis-runs` or in-process `tepp_api` is injected. New orchestrator helpers use `mode="auto"` only. diff --git a/docs/analysis-run-registry.md b/docs/analysis-run-registry.md index 5bb3b5e15..ff10aed1c 100644 --- a/docs/analysis-run-registry.md +++ b/docs/analysis-run-registry.md @@ -7,13 +7,16 @@ record and [the APA 7th traceability note](doctoring/ANALYSIS_RUN_REGISTRY_REFER for the standards mapping. There is no public create/read/update/delete API in this slice. The registry -is a database contract only. Examples use synthetic **Demo Corp** and -aggregate ranges, never private source names or exact private counts. +is a database contract only. A raw insert can store a run with no scope, no +counts, and no pending event. Treat a run as recorded only after a later +write API stores snapshot, counts, run, scope, and pending in one +transaction (ADR 0014 follow-up 1). Examples use synthetic **Demo Corp** +and aggregate ranges, never private source names or exact private counts. -## What a buyer can rely on +## What the schema can hold -An analysis run is a dated, account-owned request to derive product evidence -from one frozen source capture. The database remembers: +The tables can remember these facts once a later write API fills them +together. This slice does not invite hand-entered registry rows. 1. **What was captured** — a digest and the latest time any admitted fact could be known (`maximum_available_time`). @@ -24,7 +27,8 @@ from one frozen source capture. The database remembers: 4. **How wide the request was** — all visible records, one Demo Corp corporate entity, one process unit, or one thread group. 5. **What happened next** — append-only status events. Current status is a - view, not a second editable table. + view, not a second editable table. Status insert requires a scope; run + insert does not. ## Entity-relationship diagram diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index fb799920b..81b86c3aa 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -626,46 +626,46 @@ def test_registry_rejects_orphans_and_missing_actor(registry_db) -> None: """, (missing, missing, "b" * 64, "c" * 40), ) - snapshot_id = _insert_snapshot(cursor) - with pytest.raises(psycopg2.errors.CheckViolation): - cursor.execute( - "insert into analysis_source_count values " - "(%s, 'analysis_count_source_row', -1)", - (snapshot_id,), - ) - with pytest.raises(psycopg2.errors.CheckViolation): - cursor.execute( - "insert into analysis_source_snapshot " - "(snapshot_sha256, source_contract_version, " - "maximum_available_time, captured_at) " - "values ('bad', 'source-contract-v1', now(), now())" - ) - with pytest.raises(psycopg2.errors.NotNullViolation): - cursor.execute( - """ - insert into analysis_run - (analysis_source_snapshot_id, run_kind_code, idempotency_key, - knowledge_cutoff, configuration_schema_version, - configuration_sha256, code_revision_sha) - values (%s, 'analysis_run_report', 'missing-actor', now(), - 'report-run-v1', %s, %s) - """, - (snapshot_id, "d" * 64, "e" * 40), - ) - account_id = _insert_account(cursor) - run_id = _insert_run( - cursor, - snapshot_id=snapshot_id, - account_id=account_id, - idempotency_key="scope-orphan", + snapshot_id = _insert_snapshot(cursor) + with pytest.raises(psycopg2.errors.CheckViolation): + cursor.execute( + "insert into analysis_source_count values " + "(%s, 'analysis_count_source_row', -1)", + (snapshot_id,), + ) + with pytest.raises(psycopg2.errors.CheckViolation): + cursor.execute( + "insert into analysis_source_snapshot " + "(snapshot_sha256, source_contract_version, " + "maximum_available_time, captured_at) " + "values ('bad', 'source-contract-v1', now(), now())" + ) + with pytest.raises(psycopg2.errors.NotNullViolation): + cursor.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + knowledge_cutoff, configuration_schema_version, + configuration_sha256, code_revision_sha) + values (%s, 'analysis_run_report', 'missing-actor', now(), + 'report-run-v1', %s, %s) + """, + (snapshot_id, "d" * 64, "e" * 40), + ) + account_id = _insert_account(cursor) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="scope-orphan", + ) + with pytest.raises(psycopg2.errors.ForeignKeyViolation): + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code, corporate_entity_id) " + "values (%s, 'analysis_scope_corporate_entity', %s)", + (run_id, missing), ) - with pytest.raises(psycopg2.errors.ForeignKeyViolation): - cursor.execute( - "insert into analysis_run_scope " - "(analysis_run_id, scope_kind_code, corporate_entity_id) " - "values (%s, 'analysis_scope_corporate_entity', %s)", - (run_id, missing), - ) def test_scope_shapes_cover_all_four_vocabularies(registry_db) -> None: @@ -801,6 +801,15 @@ def test_status_history_enforces_shape_order_time_and_legal_transitions( "'2026-08-15T01:00:01Z')", (second_run_id,), ) + with pytest.raises(psycopg2.errors.RaiseException): + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at, " + "failure_code, retryable) " + "values (%s, 2, 'analysis_status_failed', " + "'2026-08-15T01:00:01Z', 'provider_timeout', true)", + (second_run_id,), + ) cursor.execute( "insert into analysis_run_status_event " "(analysis_run_id, status_ordinal, status_code, occurred_at) " @@ -1101,6 +1110,16 @@ def test_capture_after_request_and_availability_after_capture_are_rejected( idempotency_key="late-capture", requested_at="2026-08-15T00:45:00Z", ) + timely_snapshot_id = _insert_snapshot(cursor) + with pytest.raises(psycopg2.errors.CheckViolation): + _insert_run( + cursor, + snapshot_id=timely_snapshot_id, + account_id=account_id, + idempotency_key="cutoff-after-request", + knowledge_cutoff="2026-08-15T00:50:00Z", + requested_at="2026-08-15T00:45:00Z", + ) def test_status_events_cannot_be_deleted(registry_db) -> None: From 40d214daf3f93f56854f0de67dc5c25405df6c7f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 16:42:11 +0000 Subject: [PATCH 5/7] fix(auth): reject JWTs that omit kid instead of using the first JWKS key Strix HIGH vuln-0001: missing kid previously matched the first published key. Require a non-empty kid and an exact JWKS match. Co-authored-by: Seongho Bae --- CHANGELOG.d/0.76.0-analysis-run-registry.md | 6 ++ CHANGELOG.md | 5 ++ backend/app/auth.py | 7 ++- backend/tests/test_auth_jwks.py | 62 +++++++++++++++++++++ scripts/smoke_test_oidc.py | 4 +- 5 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_auth_jwks.py diff --git a/CHANGELOG.d/0.76.0-analysis-run-registry.md b/CHANGELOG.d/0.76.0-analysis-run-registry.md index d8d0389ab..6ac047cfb 100644 --- a/CHANGELOG.d/0.76.0-analysis-run-registry.md +++ b/CHANGELOG.d/0.76.0-analysis-run-registry.md @@ -11,3 +11,9 @@ - Fail-closed TEPP helpers bind `AnalysisRunRequest` to `snapshot_id` and `knowledge_cutoff` without forking TEPP arithmetic. - New contextual-orchestrator helpers request `mode="auto"` only. + +## Fixed + +- JWT verification requires a non-empty `kid` that matches a JWKS key. + Tokens that omit `kid` no longer fall back to the first published key. + diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e4239aa..8c328fb3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ All notable changes to this project are documented here. Format follows in-process `tepp_api` is injected. New orchestrator helpers use `mode="auto"` only. +### Fixed + +- JWT verification now requires a non-empty `kid` and a matching JWKS + key. A token that omits `kid` no longer falls back to the first key. + ## [0.75.0] - 2026-08-17 ### Added diff --git a/backend/app/auth.py b/backend/app/auth.py index e5f07fff2..04920cd53 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -47,8 +47,13 @@ def _signing_key_from_jwks(jwks: dict, token: str): """Pick the JWKS RSA key that matches the JWT kid, without urllib.""" header = jwt.get_unverified_header(token) kid = header.get("kid") + if not isinstance(kid, str) or not kid.strip(): + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + "JWT header is missing kid", + ) for key in jwks.get("keys", []): - if kid is None or key.get("kid") == kid: + if key.get("kid") == kid: return RSAAlgorithm.from_jwk(json.dumps(key)) raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"no JWKS key matched kid={kid!r}") diff --git a/backend/tests/test_auth_jwks.py b/backend/tests/test_auth_jwks.py new file mode 100644 index 000000000..b1ceb67a9 --- /dev/null +++ b/backend/tests/test_auth_jwks.py @@ -0,0 +1,62 @@ +"""JWKS key selection must require a matching JWT kid.""" + +from __future__ import annotations + +import json + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import HTTPException +from jwt.algorithms import RSAAlgorithm + +from backend.app.auth import _signing_key_from_jwks + + +def _rsa_jwk_and_private_key(*, kid: str) -> tuple[dict, object]: + """Return one JWKS RSA public key and the matching private key.""" + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_jwk = json.loads(RSAAlgorithm.to_jwk(private_key.public_key())) + public_jwk["kid"] = kid + public_jwk["use"] = "sig" + public_jwk["alg"] = "RS256" + return public_jwk, private_key + + +def test_signing_key_requires_kid_and_rejects_missing_or_unknown_kid() -> None: + """A token without kid must not fall back to the first JWKS key.""" + + first_jwk, first_private = _rsa_jwk_and_private_key(kid="first-key") + second_jwk, _second_private = _rsa_jwk_and_private_key(kid="second-key") + jwks = {"keys": [first_jwk, second_jwk]} + + matched = jwt.encode( + {"sub": "demo-analyst"}, + first_private, + algorithm="RS256", + headers={"kid": "first-key"}, + ) + assert _signing_key_from_jwks(jwks, matched) is not None + + missing_kid = jwt.encode( + {"sub": "demo-analyst"}, + first_private, + algorithm="RS256", + headers={}, + ) + with pytest.raises(HTTPException) as missing: + _signing_key_from_jwks(jwks, missing_kid) + assert missing.value.status_code == 401 + assert "missing kid" in missing.value.detail + + unknown_kid = jwt.encode( + {"sub": "demo-analyst"}, + first_private, + algorithm="RS256", + headers={"kid": "unknown-key"}, + ) + with pytest.raises(HTTPException) as unknown: + _signing_key_from_jwks(jwks, unknown_kid) + assert unknown.value.status_code == 401 + assert "unknown-key" in unknown.value.detail diff --git a/scripts/smoke_test_oidc.py b/scripts/smoke_test_oidc.py index 2a37b376b..9e80086e6 100644 --- a/scripts/smoke_test_oidc.py +++ b/scripts/smoke_test_oidc.py @@ -55,8 +55,10 @@ def _signing_key_from_jwks(jwks: dict, token: str): """Pick the JWKS RSA key that matches the JWT kid, without urllib.""" header = jwt.get_unverified_header(token) kid = header.get("kid") + if not isinstance(kid, str) or not kid.strip(): + raise SystemExit("JWT header is missing kid") for key in jwks.get("keys", []): - if kid is None or key.get("kid") == kid: + if key.get("kid") == kid: return RSAAlgorithm.from_jwk(json.dumps(key)) raise SystemExit(f"no JWKS key matched kid={kid!r}") From eb6ee80c56a93e64b9e7703036119c4e8d3fc134 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 17:23:12 +0000 Subject: [PATCH 6/7] fix(ci): install rustc without piping rustup.rs into a shell Strix flagged the curl | sh rustup install in tests.yml as remote script execution. CI now uses a SHA-pinned dtolnay/rust-toolchain action for 1.97.1. The backend image fetches rustup-init 1.28.2 from the versioned archive and verifies its SHA-256 before running it. Co-authored-by: Seongho Bae --- .github/workflows/tests.yml | 16 ++++++++++------ CHANGELOG.d/0.76.0-analysis-run-registry.md | 4 ++++ CHANGELOG.md | 4 ++++ backend/Dockerfile | 16 ++++++++++++---- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cb4c7f951..66ffa0bb0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,12 +29,16 @@ jobs: python-version: "3.12" - name: Install pinned Rust toolchain - run: | - # Same pin as backend/Dockerfile: fast-mlsirm's PyO3/maturin - # core has no wheel, so pip install -e ".[backend]" compiles it. - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ - sh -s -- -y --profile minimal --default-toolchain 1.97.1 - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + # Same pin as backend/Dockerfile: fast-mlsirm's PyO3/maturin + # core has no wheel, so pip install -e ".[backend]" compiles it. + # Pin the rust-toolchain action by commit SHA so CI does not pipe + # https://sh.rustup.rs into a shell (Strix CRITICAL on that pattern). + # SHA is dtolnay/rust-toolchain@master as of 2026-08-05; the action + # then installs toolchain 1.97.1. There is no rust-toolchain tag + # named 1.97.1. + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: 1.97.1 - name: Install package and test dependencies run: python -m pip install -e ".[dev,backend]" diff --git a/CHANGELOG.d/0.76.0-analysis-run-registry.md b/CHANGELOG.d/0.76.0-analysis-run-registry.md index 6ac047cfb..cd57bffac 100644 --- a/CHANGELOG.d/0.76.0-analysis-run-registry.md +++ b/CHANGELOG.d/0.76.0-analysis-run-registry.md @@ -16,4 +16,8 @@ - JWT verification requires a non-empty `kid` that matches a JWKS key. Tokens that omit `kid` no longer fall back to the first published key. +- CI installs rustc 1.97.1 through a SHA-pinned `dtolnay/rust-toolchain` + action instead of piping `https://sh.rustup.rs` into a shell. The + backend image fetches rustup-init 1.28.2 from the versioned archive + and checks its SHA-256 before running it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c328fb3f..580d6d181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ All notable changes to this project are documented here. Format follows - JWT verification now requires a non-empty `kid` and a matching JWKS key. A token that omits `kid` no longer falls back to the first key. +- CI installs rustc 1.97.1 through a SHA-pinned `dtolnay/rust-toolchain` + action instead of piping `https://sh.rustup.rs` into a shell. The + backend image fetches rustup-init 1.28.2 from the versioned archive + and checks its SHA-256 before running it. ## [0.75.0] - 2026-08-17 diff --git a/backend/Dockerfile b/backend/Dockerfile index b0c504cc8..3be6707fc 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,10 +14,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends git build-essen && useradd --uid 1000 --gid appuser --create-home appuser # Pinned, non-interactive rustup install (minimal profile: no docs/clippy, -# just rustc+cargo) -- same "pinned, reproducible install" discipline this -# project already applies to rankweave/fast-mlsirm's own commit pins. -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ - sh -s -- -y --profile minimal --default-toolchain 1.97.1 +# just rustc+cargo). Fetch rustup-init from the versioned archive and +# verify SHA-256 so the image does not pipe https://sh.rustup.rs into a +# shell. Checksum is rustup 1.28.2 for x86_64-unknown-linux-gnu. +ARG RUSTUP_VERSION=1.28.2 +ARG RUSTUP_SHA256=20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c +RUN curl --proto '=https' --tlsv1.2 -sSfL \ + "https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/x86_64-unknown-linux-gnu/rustup-init" \ + -o /tmp/rustup-init \ + && echo "${RUSTUP_SHA256} /tmp/rustup-init" | sha256sum -c - \ + && chmod +x /tmp/rustup-init \ + && /tmp/rustup-init -y --profile minimal --default-toolchain 1.97.1 \ + && rm /tmp/rustup-init ENV PATH="/root/.cargo/bin:${PATH}" COPY pyproject.toml ./ From 8fda874db2515c373965bc5129ac09f238c29e19 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 01:34:25 +0000 Subject: [PATCH 7/7] fix(docker): add real HEALTHCHECK probes to product images Strix HIGH vuln-0001 flagged missing HEALTHCHECK instructions after the rustup Dockerfile change pulled the full image set into scan scope. Backend and frontend now probe GET /healthz (frontend is a static nginx 200, not the SPA fallback). Postgres uses pg_isready; SearXNG uses its existing /healthz. Raise locked pyjwt and fastapi floors to the versions already in uv.lock. Co-authored-by: Seongho Bae --- CHANGELOG.d/0.76.0-analysis-run-registry.md | 4 ++++ CHANGELOG.md | 5 +++++ backend/Dockerfile | 3 +++ docker-compose.yml | 16 ++++++++++++++-- docker/postgres-init/Dockerfile | 3 +++ docker/searxng/Dockerfile | 3 +++ frontend/Dockerfile | 3 +++ frontend/nginx.conf | 8 ++++++++ pyproject.toml | 6 +++--- uv.lock | 6 +++--- 10 files changed, 49 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.d/0.76.0-analysis-run-registry.md b/CHANGELOG.d/0.76.0-analysis-run-registry.md index cd57bffac..df80a8d0d 100644 --- a/CHANGELOG.d/0.76.0-analysis-run-registry.md +++ b/CHANGELOG.d/0.76.0-analysis-run-registry.md @@ -20,4 +20,8 @@ action instead of piping `https://sh.rustup.rs` into a shell. The backend image fetches rustup-init 1.28.2 from the versioned archive and checks its SHA-256 before running it. +- Product images declare a real `HEALTHCHECK` against an existing probe + (`GET /healthz` on backend and frontend, `pg_isready` on Postgres, + SearXNG `/healthz`). Locked floors are now `pyjwt>=2.13.0` and + `fastapi>=0.141.1`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 580d6d181..d2f1f18e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ All notable changes to this project are documented here. Format follows action instead of piping `https://sh.rustup.rs` into a shell. The backend image fetches rustup-init 1.28.2 from the versioned archive and checks its SHA-256 before running it. +- Product images declare a real `HEALTHCHECK` against an existing probe + (`GET /healthz` on backend and frontend, `pg_isready` on Postgres, + SearXNG `/healthz`). The frontend probe is a static nginx 200, not + the SPA fallback. Locked floors are now `pyjwt>=2.13.0` and + `fastapi>=0.141.1`; `react-oidc-context` was already `^3.3.1`. ## [0.75.0] - 2026-08-17 diff --git a/backend/Dockerfile b/backend/Dockerfile index 3be6707fc..882ddf1ec 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -40,4 +40,7 @@ RUN pip install --no-cache-dir ".[backend]" \ USER appuser EXPOSE 8000 +# Process liveness only -- same contract as GET /healthz (no Postgres). +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8000/healthz || exit 1 CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.yml b/docker-compose.yml index fd144f263..c18a2080d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,10 @@ services: # product schema migration ship inside the image itself -- portable # across hosts/CI runners that don't share a filesystem with the # Docker daemon. Context is the repo root so the Dockerfile can COPY - # migrations/0001_initial_schema.sql plus later 0002-0012 upgrades + # migrations/0001_initial_schema.sql plus later 0002-0013 upgrades # (single source of truth -- tests/test_schema.py applies 0001; - # the registry contract applies 0001-0012). + # the leftover-pair contract applies through 0012; the registry + # contract applies through 0013). build: context: . dockerfile: docker/postgres-init/Dockerfile @@ -108,6 +109,12 @@ services: SEARXNG_BASE_URL: http://searxng:8080 ports: - "${BACKEND_PORT:-18420}:8000" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/healthz"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s depends_on: postgres: condition: service_healthy @@ -127,6 +134,11 @@ services: VITE_BACKEND_BASE_URL: http://localhost:${BACKEND_PORT:-18420} ports: - "${FRONTEND_PORT:-15173}:8080" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"] + interval: 30s + timeout: 5s + retries: 3 depends_on: - backend diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index e7c94bce5..0e6585828 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -23,3 +23,6 @@ COPY migrations/0013_analysis_run_registry.sql /docker-entrypoint-initdb.d/14-an # Official image already drops to this account at runtime; declare it so # the Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER postgres +# Local socket readiness. Compose still passes user/db on its own probe. +HEALTHCHECK --interval=5s --timeout=5s --retries=10 \ + CMD pg_isready diff --git a/docker/searxng/Dockerfile b/docker/searxng/Dockerfile index 97ba6a6af..5909edf6f 100644 --- a/docker/searxng/Dockerfile +++ b/docker/searxng/Dockerfile @@ -4,3 +4,6 @@ COPY settings.yml /etc/searxng/settings.yml # Dockerfile itself satisfies DS-0002 (explicit non-root USER), matching # docker/keycloak/Dockerfile's own USER declaration. USER searxng +# Same readiness probe as docker-compose.yml's searxng healthcheck. +HEALTHCHECK --interval=5s --timeout=5s --retries=10 \ + CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 30420c475..61037788a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -28,3 +28,6 @@ RUN sed -i 's,/run/nginx.pid,/tmp/nginx.pid,' /etc/nginx/nginx.conf \ && chown -R nginx:nginx /usr/share/nginx/html /var/cache/nginx /var/log/nginx USER nginx EXPOSE 8080 +# Process liveness -- GET /healthz is a static 200 from nginx.conf. +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 52d1b7cea..667f5adb0 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -3,6 +3,14 @@ server { root /usr/share/nginx/html; index index.html; + # Process liveness for the image HEALTHCHECK. Do not fall through to + # the SPA -- a missing index.html must not look like a healthy probe. + location = /healthz { + default_type text/plain; + add_header Cache-Control "no-store"; + return 200 "ok\n"; + } + # SPA fallback: react-oidc-context's redirect_uri is the app root, and # client-side routing (added in a later phase) needs every path to # resolve to index.html. diff --git a/pyproject.toml b/pyproject.toml index b10fc3bb7..e0bc0ed0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,15 +26,15 @@ dependencies = [ dev = [ "pillow>=12.3.0", "psycopg2-binary>=2.9.12", - "pyjwt[crypto]>=2.8.0", + "pyjwt[crypto]>=2.13.0", "pytest>=8.0", "httpx>=0.27.0", ] backend = [ - "fastapi>=0.115.0", + "fastapi>=0.141.1", "uvicorn[standard]>=0.30.0", "asyncpg>=0.29.0", - "pyjwt[crypto]>=2.8.0", + "pyjwt[crypto]>=2.13.0", # Speaks RESP; works against Valkey (a Redis-protocol-compatible fork) # as well as real Redis. Used for the post-activity event stream. "redis>=5.0.0", diff --git a/uv.lock b/uv.lock index 9de5184c3..7cbd3a315 100644 --- a/uv.lock +++ b/uv.lock @@ -386,12 +386,12 @@ requires-dist = [ { name = "asyncpg", marker = "extra == 'backend'", specifier = ">=0.29.0" }, { name = "certifi", specifier = ">=2024.0.0" }, { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=5006c38286a4fa1d81bcf57eeed5ce27ae743f50" }, - { name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.115.0" }, + { name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.141.1" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "pillow", marker = "extra == 'dev'", specifier = ">=12.3.0" }, { name = "psycopg2-binary", marker = "extra == 'dev'", specifier = ">=2.9.12" }, - { name = "pyjwt", extras = ["crypto"], marker = "extra == 'backend'", specifier = ">=2.8.0" }, - { name = "pyjwt", extras = ["crypto"], marker = "extra == 'dev'", specifier = ">=2.8.0" }, + { name = "pyjwt", extras = ["crypto"], marker = "extra == 'backend'", specifier = ">=2.13.0" }, + { name = "pyjwt", extras = ["crypto"], marker = "extra == 'dev'", specifier = ">=2.13.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "rankweave", git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6" }, { name = "rdflib", specifier = ">=7.0.0" },