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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,20 @@ lists the same dated tickets the period-report members already show.
Re-seed is idempotent. The empty-state copy is only for accounts that
truly have no dated open tickets.

## Phase 6-M2: authorized analysis-run evidence (read projection)

Issue #79's first buyer-visible Milestone 2 slice is a source-redacting
read of the #89 registry. `GET /api/analysis-runs` and
`GET /api/analysis-runs/{id}` require `post_read` and apply the scope
in SQL: the requester always sees their own run; a corporate-entity or
process-unit scope is visible only to affiliated accounts; a
thread-group scope is visible only when the account can already see a
post in that group; `all_visible` is requester-only. Hidden runs 404.
The payload is lookup labels plus non-negative aggregate counts -- never
source SQL, a DSN, a raw record, or a provider body. After `make seed`,
Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded ·
Demo Corp" with "3 documents".

## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only)

First of three staged slices toward the brief's weekly/monthly
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.d/0.79.0-analysis-run-authorized-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# 0.79.0 — Authorized analysis-run read projection

## Added

- `GET /api/analysis-runs` and `GET /api/analysis-runs/{id}` expose
source-redacting registry evidence to `post_read` accounts.
- Home-page Analysis runs panel shows the seeded Demo Corp lineage run
after `make seed`. Hidden scopes 404. No raw source, DSN, or provider
payload is returned.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ 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.79.0] - 2026-08-16

### Added

- Authorized analysis-run evidence on the product home page. After
`make seed`, Demo Analyst sees "Lineage reconstruction · Succeeded ·
Demo Corp" with the synthetic document count. `GET /api/analysis-runs`
is scoped in SQL: another tenant's run 404s and never appears in the
list. The payload is labels and aggregates -- never source SQL, a DSN,
or a raw record. TEPP stays behind `tepp_client`; Null channels are
unchanged.

## [0.78.0] - 2026-08-15

### Changed
Expand Down
190 changes: 190 additions & 0 deletions backend/app/analysis_run_ingestion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""Authorized, source-redacting reads of the Milestone 2 analysis-run registry.

The registry itself is issue #89 / migration 0018. This module is the
product projection: an account sees only runs they requested or whose
scope they already have ABAC authority to walk. Aggregate counts and
lookup labels come back; source SQL, DSNs, raw records, and provider
payloads never do.
"""

from __future__ import annotations

from typing import Any

import asyncpg

from backend.app.knowledge_graph import labels_for_codes

_VISIBLE_RUN_SQL = """
run.requested_by_account_id = $1
or (
scope.scope_kind_code = 'analysis_scope_corporate_entity'
and scope.corporate_entity_id = any($2::uuid[])
)
or (
scope.scope_kind_code = 'analysis_scope_process_unit'
and exists (
select 1 from account_affiliation aff
where aff.user_account_id = $1
and aff.process_unit_id = scope.process_unit_id
)
)
or (
scope.scope_kind_code = 'analysis_scope_thread_group'
and exists (
select 1 from source_post p
where p.thread_group_key = scope.scope_key
and (
p.visibility_code = 'public'
or p.corporate_entity_id = any($2::uuid[])
)
)
)
"""

_RUN_SELECT = f"""
select
run.analysis_run_id,
run.run_kind_code,
run.knowledge_cutoff,
run.requested_at,
run.configuration_schema_version,
run.configuration_sha256,
run.code_revision_sha,
scope.scope_kind_code,
scope.corporate_entity_id,
corp.entity_name as scope_entity_name,
status.status_code,
status.failure_code
from analysis_run run
join analysis_run_scope scope on scope.analysis_run_id = run.analysis_run_id
left join analysis_run_current_status status
on status.analysis_run_id = run.analysis_run_id
left join corporate_entity corp
on corp.corporate_entity_id = scope.corporate_entity_id
where {{where}}
order by run.requested_at desc
"""


def _iso(value: Any) -> str:
"""Serialize a timestamptz the same way post payloads do."""
return value.isoformat() if hasattr(value, "isoformat") else str(value)


async def _counts_by_run(
conn: asyncpg.Connection,
run_ids: list[str],
) -> dict[str, list[asyncpg.Record]]:
"""Load aggregate snapshot counts for the given runs."""
if not run_ids:
return {}
rows = await conn.fetch(
"""
select run.analysis_run_id, counts.count_type_code, counts.count_value
from analysis_run run
join analysis_source_count counts
on counts.analysis_source_snapshot_id = run.analysis_source_snapshot_id
where run.analysis_run_id = any($1::uuid[])
order by counts.count_type_code
""",
run_ids,
)
grouped: dict[str, list[asyncpg.Record]] = {}
for row in rows:
grouped.setdefault(str(row["analysis_run_id"]), []).append(row)
return grouped


async def _serialize_runs(
conn: asyncpg.Connection,
rows: list[asyncpg.Record],
) -> list[dict[str, Any]]:
"""Project registry rows into the authorized buyer-facing payload."""
if not rows:
return []
count_rows = await _counts_by_run(conn, [str(row["analysis_run_id"]) for row in rows])
labels = await labels_for_codes(
conn,
[row["run_kind_code"] for row in rows]
+ [row["scope_kind_code"] for row in rows]
+ [row["status_code"] for row in rows if row["status_code"]]
+ [
count["count_type_code"]
for counts in count_rows.values()
for count in counts
],
)
payload: list[dict[str, Any]] = []
for row in rows:
run_id = str(row["analysis_run_id"])
kind = row["run_kind_code"]
scope = row["scope_kind_code"]
status = row["status_code"]
item: dict[str, Any] = {
"analysis_run_id": run_id,
"run_kind_code": kind,
"run_kind_label": labels.get(kind, kind),
"scope_kind_code": scope,
"scope_kind_label": labels.get(scope, scope),
"status_code": status,
"status_label": labels.get(status, status) if status else None,
"knowledge_cutoff": _iso(row["knowledge_cutoff"]),
"requested_at": _iso(row["requested_at"]),
"source_counts": [
{
"count_type_code": count["count_type_code"],
"count_type_label": labels.get(
count["count_type_code"], count["count_type_code"]
),
"count_value": int(count["count_value"]),
}
for count in count_rows.get(run_id, [])
],
}
if row["scope_entity_name"]:
item["scope_entity_name"] = row["scope_entity_name"]
payload.append(item)
return payload


async def fetch_visible_analysis_runs(
conn: asyncpg.Connection,
account_id: str,
affiliated_entity_ids: list[str],
) -> list[dict[str, Any]]:
"""Runs the account requested or whose scope they may already walk."""
rows = await conn.fetch(
_RUN_SELECT.format(where=_VISIBLE_RUN_SQL),
account_id,
affiliated_entity_ids,
)
return await _serialize_runs(conn, rows)


async def fetch_visible_analysis_run(
conn: asyncpg.Connection,
analysis_run_id: str,
account_id: str,
affiliated_entity_ids: list[str],
) -> dict[str, Any] | None:
"""One visible run, or None when it is missing or hidden."""
rows = await conn.fetch(
_RUN_SELECT.format(
where=f"run.analysis_run_id = $3 and ({_VISIBLE_RUN_SQL})"
),
account_id,
affiliated_entity_ids,
analysis_run_id,
)
payload = await _serialize_runs(conn, rows)
if not payload:
return None
detail = payload[0]
row = rows[0]
detail["configuration_schema_version"] = row["configuration_schema_version"]
detail["configuration_sha256"] = row["configuration_sha256"]
detail["code_revision_sha"] = row["code_revision_sha"]
if row["failure_code"]:
detail["failure_code"] = row["failure_code"]
return detail
49 changes: 49 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import asyncio
from contextlib import asynccontextmanager
from typing import Any
from uuid import UUID

import asyncpg
import redis.asyncio as redis
Expand Down Expand Up @@ -64,6 +65,10 @@
from lineageweave.post_summary import ContextualOrchestratorPostSummaryClient, NullPostSummaryClient
from lineageweave.relation_verification import NullRelationVerificationClient, SearxngRelationVerificationClient

from backend.app.analysis_run_ingestion import (
fetch_visible_analysis_run,
fetch_visible_analysis_runs,
)
from backend.app.activity_stream import (
create_valkey_client,
get_valkey,
Expand Down Expand Up @@ -1148,6 +1153,50 @@ async def derive_post_commitment(
return {"post_id": str(post["post_id"]), "has_commitment": True, "ticket": ticket}


@app.get("/api/analysis-runs")
async def list_analysis_runs(
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""Authorized analysis-run list: aggregates and labels only.

Hidden scopes 404 at the item path and never appear here. The
payload has no source SQL, DSN, raw record, or provider body.
"""
_require_post_read(account)
async with pool.acquire() as conn:
runs = await fetch_visible_analysis_runs(
conn,
account.user_account_id,
list(account.corporate_entity_ids),
)
return {"analysis_runs": runs}


@app.get("/api/analysis-runs/{analysis_run_id}")
async def read_analysis_run(
analysis_run_id: str,
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""One authorized analysis-run projection, or 404 when hidden."""
_require_post_read(account)
try:
UUID(analysis_run_id)
except ValueError:
raise HTTPException(status.HTTP_404_NOT_FOUND, "analysis run not found") from None
async with pool.acquire() as conn:
run = await fetch_visible_analysis_run(
conn,
analysis_run_id,
account.user_account_id,
list(account.corporate_entity_ids),
)
if run is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "analysis run not found")
return run


@app.get("/api/calendar")
async def read_calendar(
account: CurrentAccount = Depends(get_current_account),
Expand Down
Loading