Skip to content
Open
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
12 changes: 7 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,11 +471,13 @@ 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, 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.
`POST /api/analysis-runs` records a Pending lineage run on a new
authorized cutoff capture (ADR 0017): snapshot, counts, run, scope, and
the first status in one transaction. TEPP and period-report kinds are
422. It does not reconstruct lineage and does not invent a TEPP score.
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.
`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
7 changes: 7 additions & 0 deletions CHANGELOG.d/0.87.1-analysis-run-lineage-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 0.87.1 Analysis-run write is lineage-only

`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).

## [0.87.1] - 2026-08-16

### 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`). A failed lineage row
names that button.

## [0.87.0] - 2026-08-16

### Added
Expand Down
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ back 0020 then 0018. The published phrase is not a secret. Do not
retention grant to the application `DATABASE_URL` login. ADR 0019
is the R&R catalog-id bind, not this purge.

## 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 @@ -31,5 +31,8 @@ lineage row says reconstruction has not started yet.
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.
`POST /api/analysis-runs` records Pending lineage only on an
authorized cutoff capture (ADR 0017). TEPP and period-report kinds
are 422. It does not invent a TEPP theta. The Request button waits
until affiliated corps load; choose a corp if the token walks more
than one.
44 changes: 33 additions & 11 deletions backend/app/analysis_run_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
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. It records lineage only.
It does not reconstruct lineage, accept a TEPP kind, or invent a score.
"""

from __future__ import annotations
Expand All @@ -25,7 +25,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 @@ -319,6 +321,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 @@ -443,15 +470,10 @@ async def create_pending_analysis_run(
) -> dict[str, Any]:
"""Insert snapshot, counts, run, scope, and Pending in one transaction.

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.
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 @@ -312,12 +312,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 @@ -1211,8 +1238,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 @@ -1228,11 +1255,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
31 changes: 30 additions & 1 deletion backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,12 +546,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 @@ -581,6 +607,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 @@ -51,9 +51,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
59 changes: 51 additions & 8 deletions docs/adr/0017-authorized-analysis-run-create.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# ADR 0017 — Operators request an analysis run through the product API
# ADR 0017 — Operators request a pending lineage run on an authorized capture

**Decision status:** Accepted on this active PR; not protected-main truth until merge
**Date:** 2026-08-16
Expand All @@ -14,30 +14,66 @@ ADR 0013 already required a transaction that creates snapshot, counts,
run, scope, and the first status atomically. Follow-up 3 (outbox / worker)
still owns reconstruction and live TEPP execution.

`#125` landed that write and also accepted a TEPP kind. A Pending TEPP
row that never called `tepp_client` is a fabricated measurement request.
This decision keeps the live cutoff capture and closes that hole.

## Decision

`POST /api/analysis-runs` is the authorized write:

- `post_read` is enough. The caller may only cover a corporate entity
they already walk. An unaffiliated corp is 404, not 403.
- Only `analysis_run_lineage` is accepted. TEPP stays a `tepp_client`
wire path (`tepp_not_available` / `tepp_result_not_persisted`). Period
reports stay on the Reports panel rebuild.
- The capture digest hashes scope, entity, cutoff, and authorized post
ids — never a post body, DSN, or source SQL.
ids — never a post body, DSN, source SQL, or a theta.
- The write inserts snapshot, aggregate counts, `analysis_run`,
`analysis_run_scope`, and `analysis_status_pending` in one transaction.
- The first status is Pending. This slice does not reconstruct lineage
and does not call TEPP. A missing measurement stays Failed only on the
seed path that already goes through `tepp_client`.
and does not call TEPP.
- Account-scoped idempotency compares `configuration_sha256`. An omitted
cutoff is hashed as `unspecified` so a retry of the same client key
does not conflict because the clock moved.
- `GET /api/me` returns the affiliated `corporate_entities` so a
multi-affiliation operator can choose which entity to reconstruct.
- The response is the same authorized detail as `GET /api/analysis-runs/{id}`.

```mermaid
sequenceDiagram
participant Operator
participant API
participant Registry
Operator->>API: POST /api/analysis-runs
alt TEPP, report, or unknown kind
API-->>Operator: 422 next-action (no registry write)
else same account+key+digest
API->>Registry: compare configuration digest
Registry-->>API: existing run
API-->>Operator: 201 replay
else same key, different digest
API-->>Operator: 409 conflict
else lineage kind, new key
API->>Registry: capture authorized cutoff bag
Registry->>Registry: snapshot + counts + run + scope + pending
API-->>Operator: 201 Pending row
end
```

The home panel's **Request a lineage reconstruction** button stays
disabled until `GET /api/me` returns affiliated corps, then records
that Pending row for the chosen entity. A failed lineage row names
that button. Only a failed TEPP row mentions the measurement service.

## Consequences

The home panel's **Request a lineage reconstruction** button records a
Pending row the operator can open immediately. Reconstruction, TEPP
transport, and the outbox worker remain later slices. Do not stamp
Succeeded or invent a theta from this write.
- Demo Analyst can request a new Pending Demo Corp lineage run after
`make seed` without inventing a measurement.
- A multi-affiliation account sees the corp picker before the Request
button enables, then chooses the corp before clicking.
- Reconstruction, live TEPP transport, and the outbox worker remain
later slices. Do not stamp Succeeded or invent a theta from this write.

## References — APA 7th

Expand All @@ -49,8 +85,15 @@ Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management.
*IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44.
https://doi.org/10.1109/69.755613

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

World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C
Recommendation). https://www.w3.org/TR/owl-time/
2 changes: 1 addition & 1 deletion docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
| 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, `invoking_session_role` on each retention event, and exclusion of raw source/provider payloads. |
| NIST SP 800-53 Rev. 5 AC-3 | Enforce least privilege on privileged procedures; a well-known procedure name is not an authorization secret. | `REVOKE ALL` on `purge_analysis_run_registry` from `PUBLIC`; `GRANT EXECUTE` only to `analysis_run_retention_admin`; unrevoked `analysis_run_retention_grant` required (ADR 0020). |
| OpenAPI 3.2.0 | Define explicit versioned API schemas rather than exposing database rows or implementation-specific payloads. | API intentionally deferred; ADR 0013 requires a source-redacting run list/detail contract before a product surface is claimed. |
| OpenAPI 3.2.0 | Define explicit versioned API schemas rather than exposing database rows or implementation-specific payloads. | `GET /api/analysis-runs`, `GET /api/analysis-runs/{id}`, and `POST /api/analysis-runs` return the authorized projection (labels, clocks, aggregates). TEPP/report creates are 422. |

## Temporal reasoning

Expand Down
2 changes: 1 addition & 1 deletion docs/doctoring/DESIGN_TOKEN_REFERENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ the Storybook inventory.

| Source | Product implication | Implemented evidence |
|---|---|---|
| W3C Design Tokens Format Module 1.0 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--radius-chip`, and `--font-*`. `CitationChip` and `PopupCloseButton` read those names through `App.css`. |
| W3C Design Tokens Format Module 1.0 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--size-control-min`, `--radius-chip`, `--radius-control`, and `--font-*`. `CitationChip`, `PopupCloseButton`, and `LineageEntityPicker` read those names through `App.css`. |
| Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. |

## APA 7th references
Expand Down
1 change: 1 addition & 0 deletions docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ buyer-facing control you can click before changing product CSS.
|---|---|---|
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
| `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` |

Repeated web objects must use `frontend/src/styles/tokens.css` and a module
under `frontend/src/components/`. Do not add a second Node package manager;
Expand Down
Loading
Loading