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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ A run-bearing analysis-run registry empties only after an unrevoked
(ADR 0020 / v0.87.0). The documented phrase is not a secret. Do not
expose purge on a public HTTP route.

`POST /api/analysis-runs/{id}/start` reconstructs a Pending lineage
cutoff bag through `reconstruct()` / `lineage_edge_specs` (ADR 0021 /
v0.88.0). TEPP and period-report start stay 422. Do not invent a theta.

## CI gates

`.github/workflows/tests.yml` runs the full suite on every PR to `main`.
Expand Down
15 changes: 10 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,10 +472,14 @@ run's scope whose `created_at` is at or before `knowledge_cutoff`
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.
cutoff capture (ADR 0017): snapshot, counts, frozen membership, run,
scope, and the first status in one transaction.
`POST /api/analysis-runs/{id}/start` then runs ThreadWeave on that
frozen bag and persists run-scoped edges (ADR 0021). 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 All @@ -492,7 +496,8 @@ calibrated negative result. A failed lineage row tells the operator
to retry reconstruction, not to connect TEPP. A failed period-report
row tells the operator to rebuild the report. A pending TEPP row
does not claim a calibrated measurement. A pending lineage row
says reconstruction has not started yet. The
says reconstruction has not started yet; open it and start
reconstruction. 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 ·
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.d/0.88.0-analysis-run-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# 0.88.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.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ 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.88.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 0021). Open the Pending Demo Corp row, then start reconstruction.
The designed A-100 fork (revised quote and delivery question under the
pricing follow-up) is the acceptance tree. TEPP and period-report
start are 422 — this path does not invent a theta. A Succeeded retry
returns the stored digest. A Running restart is 409. Create freezes
authorized post ids so start cannot pick up a later backfill. Live
Event Lineage stays a separate rebuild.

## [0.87.0] - 2026-08-16

### Added
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,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 0021) and does not invent a
theta. Hover the Result prefix to read the parent-choice digest.
124 changes: 120 additions & 4 deletions backend/app/analysis_run_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
lookup labels come back; source SQL, DSNs, raw records, and provider
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.
``create_pending_analysis_run`` (ADR 0017) writes snapshot, counts, frozen
membership, run, scope, and the first Pending event atomically.
``start_pending_analysis_run`` (ADR 0021) 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 0021 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 @@ -441,7 +556,7 @@ async def create_pending_analysis_run(
knowledge_cutoff: datetime | None,
idempotency_key: str,
) -> dict[str, Any]:
"""Insert snapshot, counts, run, scope, and Pending in one transaction.
"""Insert snapshot, counts, frozen members, run, scope, and Pending.

Does not reconstruct lineage and does not call TEPP. A missing
measurement stays a later worker slice; this write only records the
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
Loading