Skip to content
Closed
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
10 changes: 7 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,9 +473,13 @@ without seeing later live rows or hidden bodies. Detail also returns
revision and configuration digest prefixes.
`POST /api/analysis-runs` records a Pending run on a new authorized
cutoff capture (ADR 0017): snapshot, counts, run, scope, and the first
status in one transaction. It does not reconstruct lineage and does not
invent a TEPP score. Request a lineage reconstruction from the home
list, then open the Pending row to confirm the cutoff corpus.
status in one transaction. `POST /api/analysis-runs/{id}/start` then
runs ThreadWeave on the frozen snapshot membership and persists
run-scoped edges (ADR 0019). It does not invent a TEPP score. Request a
lineage reconstruction from the home list, open the Pending row, then
start reconstruction. Hover the Result digest prefix, then confirm the
designed A-100 fork before treating the live Event Lineage panel as
that run's tree.
`make seed` also records a TEPP measurement run through
`tepp_client` on that same snapshot; the default transport is
unavailable, so that run is Failed rather than a fabricated score.
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.d/0.87.0-analysis-run-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# 0.87.0 start a pending lineage reconstruction

`POST /api/analysis-runs/{id}/start` runs ThreadWeave on a Pending
lineage cutoff bag and persists run-scoped edges. Start reconstruction
from the open run. This path does not invent a TEPP measurement.
6 changes: 6 additions & 0 deletions CHANGELOG.d/0.87.1-analysis-run-start-bag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# 0.87.1 start reconstructs the create-time cutoff bag

Start locks the run before writing Running, persists snapshot membership
at create, and shows the Result digest prefix. Refresh after a double
start to see the stored tree. This path still does not invent a TEPP
measurement.
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ 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.87.1] - 2026-08-16

### Fixed

- Start locks the Pending run before writing Running, so a double-click
is a next-action 409 instead of a 500. Create now freezes authorized
post ids on `analysis_source_snapshot_member`; start reconstructs that
bag instead of a later backfill that shares the cutoff clock. Hover
the Result digest prefix after Succeeded to verify the parent-choice
hash. Edge titles stay public-or-affiliated. Open the Pending Demo
Corp row, start reconstruction, and confirm the A-100 fork.

## [0.87.0] - 2026-08-16

### Added

- `POST /api/analysis-runs/{id}/start` runs ThreadWeave on a visible
Pending lineage cutoff bag and persists run-scoped parent choices
(ADR 0019). Open the Pending run, then start reconstruction. The
designed A-100 fork (revised quote and delivery question under the
pricing follow-up) is the acceptance tree. TEPP start is 422 — this
path does not invent a theta. A Succeeded retry returns the stored
digest. Live Event Lineage stays a separate rebuild.

## [0.86.0] - 2026-08-16

### Added
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Tool-specific pointer. Policy lives in [AGENTS.md](AGENTS.md) and the
ADRs under `docs/adr/`. Do not fork those rules here.

## Analysis-run seed (v0.85.0)
## Analysis-run seed (v0.87.1)

`make seed` writes a Demo Corp lineage run and a TEPP run on the same
snapshot (ADR 0013). The TEPP path goes through `tepp_client`. A missing
Expand All @@ -18,4 +18,6 @@ Digest prefixes stay audible; hover a prefix to read the full digest.
Opening a cutoff title shows the live post -- compare it with the
cutoff before treating the body as reconstructed evidence (ADR 0016).
`POST /api/analysis-runs` records Pending on an authorized
cutoff capture (ADR 0017) and does not reconstruct lineage.
cutoff capture (ADR 0017). `POST /api/analysis-runs/{id}/start`
reconstructs that frozen cutoff bag (ADR 0019) and does not invent a
theta. Hover the Result prefix to read the parent-choice digest.
120 changes: 118 additions & 2 deletions backend/app/analysis_run_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
payloads never do.

``create_pending_analysis_run`` (ADR 0017) writes snapshot, counts, run,
scope, and the first Pending event atomically. It does not reconstruct
lineage or invent a TEPP score.
scope, and the first Pending event atomically. ``start_pending_analysis_run``
(ADR 0019) later reconstructs lineage on that cutoff bag. Neither path
invents a TEPP score.
"""

from __future__ import annotations
Expand Down Expand Up @@ -247,9 +248,123 @@ async def fetch_visible_analysis_run(
affiliated_entity_ids,
row["knowledge_cutoff"],
)
digest, edges = await fetch_reconstructed_edges(
conn,
analysis_run_id,
affiliated_entity_ids,
)
if digest is not None:
detail["reconstruction_result_sha256"] = digest
detail["reconstructed_edges"] = edges
return detail


def reconstructed_edge_is_visible(
*,
parent_visibility_code: str,
parent_corporate_entity_id: Any,
child_visibility_code: str,
child_corporate_entity_id: Any,
affiliated_entity_ids: list[str],
) -> bool:
"""Hide an edge when either endpoint is outside the caller's ABAC bag."""
affiliated = {str(entity_id) for entity_id in affiliated_entity_ids}
parent_visible = (
parent_visibility_code == "public"
or str(parent_corporate_entity_id) in affiliated
)
child_visible = (
child_visibility_code == "public"
or str(child_corporate_entity_id) in affiliated
)
return parent_visible and child_visible


async def fetch_reconstructed_edges(
conn: asyncpg.Connection,
analysis_run_id: str,
affiliated_entity_ids: list[str],
) -> tuple[str | None, list[dict[str, Any]]]:
"""Return the persisted digest and titled edges, or ``(None, [])``.

Missing reconstruction tables mean this database has not applied
migration 0019 yet; treat that as no stored tree rather than 500.
Titles follow the same public-or-affiliated rule as ``visible_posts``.
"""
try:
header = await conn.fetchrow(
"""
select result_sha256
from analysis_run_reconstruction
where analysis_run_id = $1
""",
analysis_run_id,
)
except asyncpg.UndefinedTableError:
return None, []
if header is None:
return None, []
rows = await conn.fetch(
"""
select
edge.parent_post_id,
parent_post.post_title as parent_post_title,
parent_post.visibility_code as parent_visibility_code,
parent_post.corporate_entity_id as parent_corporate_entity_id,
edge.child_post_id,
child_post.post_title as child_post_title,
child_post.visibility_code as child_visibility_code,
child_post.corporate_entity_id as child_corporate_entity_id,
edge.fused_score
from analysis_run_lineage_edge edge
join source_post parent_post on parent_post.post_id = edge.parent_post_id
join source_post child_post on child_post.post_id = edge.child_post_id
where edge.analysis_run_id = $1
order by parent_post.post_title, child_post.post_title
""",
analysis_run_id,
)
return header["result_sha256"], [
{
"parent_post_id": str(row["parent_post_id"]),
"parent_post_title": row["parent_post_title"],
"child_post_id": str(row["child_post_id"]),
"child_post_title": row["child_post_title"],
"fused_score": float(row["fused_score"]),
}
for row in rows
if reconstructed_edge_is_visible(
parent_visibility_code=row["parent_visibility_code"],
parent_corporate_entity_id=row["parent_corporate_entity_id"],
child_visibility_code=row["child_visibility_code"],
child_corporate_entity_id=row["child_corporate_entity_id"],
affiliated_entity_ids=affiliated_entity_ids,
)
]


async def persist_snapshot_members(
conn: asyncpg.Connection,
snapshot_id: Any,
post_ids: list[str],
) -> None:
"""Freeze authorized post ids on a new snapshot. Skip a legacy database."""
if not post_ids:
return
try:
await conn.executemany(
"""
insert into analysis_source_snapshot_member
(analysis_source_snapshot_id, source_post_id)
values ($1, $2)
on conflict do nothing
""",
[(snapshot_id, post_id) for post_id in post_ids],
)
except asyncpg.UndefinedTableError:
return


async def fetch_visible_scope_posts(
conn: asyncpg.Connection,
scope_kind_code: str,
Expand Down Expand Up @@ -574,6 +689,7 @@ async def create_pending_analysis_run(
capture.document_count,
capture.thread_count,
)
await persist_snapshot_members(conn, snapshot_id, post_ids)
try:
run_id = await conn.fetchval(
"""
Expand Down
Loading