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: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,11 @@ 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` records Pending lineage only (ADR 0017 /
v2.7.1). TEPP and period-report kinds 422 before any snapshot write.
`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.
v0.88.0). Do not invent a theta.
Opening a cutoff-rewritten title shows **Body this run knew** from
`source_post_revision` beside the live rewrite (ADR 0025 / v2.1.0).
Do not invent the earlier sentence when no revision covers the cutoff.
Expand Down
9 changes: 6 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,9 +471,12 @@ run's scope whose `created_at` is at or before `knowledge_cutoff`
(ADR 0016) so a buyer can open a post the run was allowed to know
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, frozen membership, run,
scope, and the first status in one transaction.
`POST /api/analysis-runs` records a Pending lineage run on a new
authorized cutoff capture (ADR 0017): snapshot, counts, frozen
membership, run, scope, and the first status in one transaction. TEPP
and period-report kinds are 422. Request a lineage reconstruction from
the home list after affiliated corps load (choose a corp if you walk
more than one), then open the Pending row to confirm the cutoff corpus.
`POST /api/analysis-runs/{id}/start` then commits Running plus a
durable outbox row, wakes Valkey, and delivers ThreadWeave on that
frozen bag (ADR 0021 / ADR 0023) or submits TEPP through
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.d/2.7.1-wait-affiliated-corps-lineage-create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 2.7.1 Wait for affiliated corps before lineage create

`POST /api/analysis-runs` records Pending lineage on an authorized
cutoff capture. TEPP and period-report kinds are 422. Open Analysis
runs and wait until affiliated corps load; choose a corp if you walk
more than one, then click Request a lineage reconstruction. Preview
the picker in Storybook (`Analysis/LineageEntityPicker`).
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).

## [2.7.1] - 2026-08-17

### Fixed

- `POST /api/analysis-runs` records Pending lineage only (ADR 0017).
TEPP and period-report kinds are 422 so this path cannot invent a
measurement. Open Analysis runs and wait until affiliated corps
load; choose a corp if you walk more than one, then click
**Request a lineage reconstruction**. Preview the picker in
Storybook (`Analysis/LineageEntityPicker`). Failed TEPP stays
terminal on this write.

## [2.7.0] - 2026-08-17

### Added
Expand Down
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@ after cutoff were rewritten after the run; the opened body names
both clocks and shows **Body this run knew** beside the live
rewrite. Compare those two texts before treating the live body as
reconstructed evidence (ADR 0016 / 0025).
`POST /api/analysis-runs` records Pending on an authorized
cutoff capture (ADR 0017). `POST /api/analysis-runs/{id}/start`
`POST /api/analysis-runs` records Pending lineage only on an
authorized cutoff capture (ADR 0017). TEPP and period-report kinds
are 422. The Request button waits until affiliated corps load; choose
a corp if the token walks more than one. `POST /api/analysis-runs/{id}/start`
commits Running plus a durable outbox row, then reconstructs that
frozen cutoff bag (ADR 0021 / ADR 0023) or submits TEPP through
`tepp_client` (ADR 0022). A missing transport or unused accepted
envelope is Failed. Failed TEPP is terminal — request a new run,
then start. Do not invent a theta. Hover the Result prefix to read
envelope is Failed. Failed TEPP is terminal — connect a TEPP
transport from that Failed row. Create does not invent a Pending
TEPP row. Do not invent a theta. Hover the Result prefix to read
the parent-choice digest.
After `make seed`, open **Period report · Succeeded · Demo Corp**,
then **Open period report 2026-W02**. The home week is already
Expand Down
51 changes: 38 additions & 13 deletions backend/app/analysis_run_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
payloads never do.

``create_pending_analysis_run`` (ADR 0017) writes snapshot, counts, frozen
membership, run, scope, and the first Pending event atomically.
``enqueue_pending_analysis_run`` then ``deliver_queued_analysis_run``
later reconstruct lineage (ADR 0021 / ADR 0023) or submit TEPP through
``tepp_client`` (ADR 0022). Neither path invents a TEPP score.
membership, run, scope, and the first Pending event atomically. It
records lineage only. It does not reconstruct lineage, accept a TEPP
kind, or invent a score. ``enqueue_pending_analysis_run`` then
``deliver_queued_analysis_run`` later reconstruct lineage (ADR 0021 /
ADR 0023) or submit TEPP through ``tepp_client`` (ADR 0022). Neither
path invents a TEPP score.
"""

from __future__ import annotations
Expand All @@ -27,7 +29,9 @@
from backend.app.knowledge_graph import labels_for_codes
from lineageweave import __version__ as PACKAGE_VERSION

_ALLOWED_CREATE_KINDS = frozenset({"analysis_run_lineage", "analysis_run_tepp"})
_LINEAGE_RUN_KIND = "analysis_run_lineage"
_TEPP_RUN_KIND = "analysis_run_tepp"
_REPORT_RUN_KIND = "analysis_run_report"
_CORPORATE_SCOPE = "analysis_scope_corporate_entity"
_CAPTURE_CONTRACT_VERSION = "analysis-run-capture-v1"
_KIND_SCHEMA_VERSION = {
Expand Down Expand Up @@ -578,6 +582,31 @@ def __init__(self, status_code: int, detail: str) -> None:
self.detail = detail


def _require_lineage_create_kind(run_kind_code: str) -> None:
"""Reject TEPP and report writes so this path cannot fake those products.

TEPP stays a ``tepp_client`` wire path. Period reports stay on the
Reports panel rebuild. A Pending TEPP row that never called the
transport is a fabricated measurement request.
"""
if run_kind_code == _TEPP_RUN_KIND:
raise AnalysisRunCreateError(
422,
"Connect a TEPP transport from a Failed TEPP row; this endpoint "
"does not invent a measurement.",
)
if run_kind_code == _REPORT_RUN_KIND:
raise AnalysisRunCreateError(
422,
"Rebuild the period report from the Reports panel.",
)
if run_kind_code != _LINEAGE_RUN_KIND:
raise AnalysisRunCreateError(
422,
"Only lineage reconstruction can be requested here.",
)


@dataclass(frozen=True)
class AnalysisRunCapture:
"""Immutable capture plan for one authorized create (no source rows)."""
Expand Down Expand Up @@ -702,15 +731,11 @@ async def create_pending_analysis_run(
) -> dict[str, Any]:
"""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
request. Idempotent retries compare ``configuration_sha256``.
Lineage only. Does not reconstruct, call TEPP, or invent a theta.
Kind rejection happens before any snapshot or run insert.
Idempotent retries compare ``configuration_sha256``.
"""
if run_kind_code not in _ALLOWED_CREATE_KINDS:
raise AnalysisRunCreateError(
422,
"Request a lineage reconstruction or a TEPP measurement. Other kinds are not available yet.",
)
_require_lineage_create_kind(run_kind_code)
if scope_kind_code != _CORPORATE_SCOPE:
raise AnalysisRunCreateError(
422,
Expand Down
42 changes: 35 additions & 7 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,39 @@ async def healthz() -> dict[str, str]:


@app.get("/api/me")
async def read_me(account: CurrentAccount = Depends(get_current_account)) -> dict[str, Any]:
"""Return the provisioned account that the bearer token resolved to."""
async def read_me(
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""Return the provisioned account and the corps this token may walk.

Multi-affiliation operators need those names to choose which entity
``POST /api/analysis-runs`` should cover.
"""
entities: list[dict[str, str]] = []
if account.corporate_entity_ids:
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
select corporate_entity_id, entity_name
from corporate_entity
where corporate_entity_id = any($1::uuid[])
order by entity_name
""",
list(account.corporate_entity_ids),
)
entities = [
{
"corporate_entity_id": str(row["corporate_entity_id"]),
"entity_name": row["entity_name"],
}
for row in rows
]
return {
"user_account_id": account.user_account_id,
"display_name": account.display_name,
"permission_codes": sorted(account.permission_codes),
"corporate_entities": entities,
}


Expand Down Expand Up @@ -1243,8 +1270,8 @@ class CreateAnalysisRunRequest(BaseModel):
"""JSON body for ``POST /api/analysis-runs``.

Omitting ``corporate_entity_id`` uses the account's sole affiliation.
Reconstruction and TEPP execution stay later slices; this write
records Pending only.
Only ``analysis_run_lineage`` is accepted. Reconstruction and TEPP
execution stay later slices; this write records Pending lineage only.
"""

run_kind_code: str = "analysis_run_lineage"
Expand All @@ -1260,11 +1287,12 @@ async def create_analysis_run(
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""Record a Pending analysis run on an authorized cutoff capture.
"""Record a Pending lineage run on an authorized cutoff capture.

post_read is enough: the caller requests a run of a corp they
already walk. The payload is the same authorized detail as GET.
Hidden scopes 404. A matching idempotent retry returns the same run.
already walk. TEPP and period-report kinds are 422 so this path
cannot invent a measurement. Hidden scopes 404. A matching
idempotent retry returns the same run.
"""
_require_post_read(account)
async with pool.acquire() as conn:
Expand Down
94 changes: 90 additions & 4 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,12 +595,38 @@ def test_create_analysis_run_records_pending_without_inventing_a_score(
assert replay.status_code == 201
assert replay.json()["analysis_run_id"] == body["analysis_run_id"]

conflict = client.post(
tepp = client.post(
"/api/analysis-runs",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
json={
"run_kind_code": "analysis_run_tepp",
"corporate_entity_id": seeded_db["own_corp_id"],
"idempotency_key": "buyer-create-tepp",
},
)
assert tepp.status_code == 422
assert "invent a measurement" in tepp.json()["detail"]
assert "theta" not in tepp.json()["detail"].lower()

report = client.post(
"/api/analysis-runs",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
json={
"run_kind_code": "analysis_run_report",
"corporate_entity_id": seeded_db["own_corp_id"],
"idempotency_key": "buyer-create-report",
},
)
assert report.status_code == 422
assert "Reports panel" in report.json()["detail"]

conflict = client.post(
"/api/analysis-runs",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
json={
"run_kind_code": "analysis_run_lineage",
"corporate_entity_id": seeded_db["own_corp_id"],
"knowledge_cutoff": "2026-01-01T00:00:00Z",
"idempotency_key": "buyer-create-2026-w02",
},
)
Expand Down Expand Up @@ -727,7 +753,7 @@ def test_start_analysis_run_recovers_the_a100_fork(
assert replay.status_code == 200
assert replay.json()["reconstruction_result_sha256"] == body["reconstruction_result_sha256"]

tepp = client.post(
tepp_create = client.post(
"/api/analysis-runs",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
json={
Expand All @@ -737,9 +763,66 @@ def test_start_analysis_run_recovers_the_a100_fork(
"idempotency_key": "buyer-start-tepp-2026-w07",
},
)
assert tepp.status_code == 201
assert tepp_create.status_code == 422
assert "invent a measurement" in tepp_create.json()["detail"]

admin_conn = psycopg2.connect(seeded_db["dsn"])
admin_conn.autocommit = True
try:
with admin_conn.cursor() as cur:
cur.execute(
"select requested_by_account_id from analysis_run where analysis_run_id = %s",
(run_id,),
)
requester_id = cur.fetchone()[0]
cur.execute(
"""
insert into analysis_source_snapshot
(snapshot_sha256, source_contract_version,
maximum_available_time, captured_at)
values (%s, 'source-contract-v1',
'2026-02-15T00:00:00Z', '2026-02-15T00:05:00Z')
returning analysis_source_snapshot_id
""",
("t" * 64,),
)
tepp_snapshot_id = cur.fetchone()[0]
cur.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_tepp', 'buyer-start-tepp-seeded',
%s, '2026-02-15T00:00:00Z', 'tepp-run-v1', %s, %s,
'2026-02-15T12:30:00Z')
returning analysis_run_id
""",
(tepp_snapshot_id, requester_id, "u" * 64, "v" * 40),
)
tepp_run_id = str(cur.fetchone()[0])
cur.execute(
"""
insert into analysis_run_scope
(analysis_run_id, scope_kind_code, corporate_entity_id)
values (%s, 'analysis_scope_corporate_entity', %s)
""",
(tepp_run_id, seeded_db["own_corp_id"]),
)
cur.execute(
"""
insert into analysis_run_status_event
(analysis_run_id, status_ordinal, status_code, occurred_at)
values (%s, 1, 'analysis_status_pending', '2026-02-15T12:31:00Z')
""",
(tepp_run_id,),
)
finally:
admin_conn.close()

measured = client.post(
f"/api/analysis-runs/{tepp.json()['analysis_run_id']}/start",
f"/api/analysis-runs/{tepp_run_id}/start",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
)
assert measured.status_code == 200, measured.text
Expand Down Expand Up @@ -957,6 +1040,9 @@ def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> No
body = response.json()
assert body["display_name"] == "Test Analyst"
assert "post_read" in body["permission_codes"]
assert any(
entity["entity_name"] == "Test Corp" for entity in body["corporate_entities"]
)


def test_post_list_includes_public_and_own_corp_but_excludes_other_corp(client, demo_analyst_token, seeded_db) -> None:
Expand Down
7 changes: 4 additions & 3 deletions docs/adr/0014-authorized-analysis-run-read.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ A pending or running TEPP row must not claim a calibrated
measurement. A pending lineage row says reconstruction has not
started yet. The detail now shows the legal
lifecycle the registry already stored. `POST /api/analysis-runs` now
records a Pending run on an authorized cutoff capture (ADR 0017).
Reconstruction, a live TEPP transport, and a fuller Analysis Run
Console remain later slices.
records a Pending lineage run on an authorized cutoff capture
(ADR 0017). TEPP and period-report kinds are 422. Reconstruction, a
live TEPP transport, and a fuller Analysis Run Console remain later
slices.

## References

Expand Down
Loading